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(),
|
subKeyFile: z.string(),
|
||||||
subListen: z.string(),
|
subListen: z.string(),
|
||||||
subPath: z.string(),
|
subPath: z.string(),
|
||||||
subPort: z.number().int().min(1).max(65535),
|
subPort: z.number().int().min(0).max(65535),
|
||||||
subProfileUrl: z.string(),
|
subProfileUrl: z.string(),
|
||||||
subRoutingRules: z.string(),
|
subRoutingRules: z.string(),
|
||||||
subShowIdentityOnAllLinks: z.boolean(),
|
subShowIdentityOnAllLinks: z.boolean(),
|
||||||
@@ -214,7 +214,7 @@ export const AllSettingViewSchema = z.object({
|
|||||||
subKeyFile: z.string(),
|
subKeyFile: z.string(),
|
||||||
subListen: z.string(),
|
subListen: z.string(),
|
||||||
subPath: z.string(),
|
subPath: z.string(),
|
||||||
subPort: z.number().int().min(1).max(65535),
|
subPort: z.number().int().min(0).max(65535),
|
||||||
subProfileUrl: z.string(),
|
subProfileUrl: z.string(),
|
||||||
subRoutingRules: z.string(),
|
subRoutingRules: z.string(),
|
||||||
subShowIdentityOnAllLinks: z.boolean(),
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { Card, theme } from 'antd';
|
import { Card, theme } from 'antd';
|
||||||
|
|
||||||
import { Sparkline } from '@/components/viz';
|
|
||||||
import type { Status } from '@/models/status';
|
import type { Status } from '@/models/status';
|
||||||
|
|
||||||
interface ConnectionsCardProps {
|
interface ConnectionsCardProps {
|
||||||
status: Status;
|
status: Status;
|
||||||
tcp: number[];
|
|
||||||
udp: number[];
|
|
||||||
labels: string[];
|
|
||||||
isMobile: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ConnectionsCard({
|
export default function ConnectionsCard({ status }: ConnectionsCardProps) {
|
||||||
status,
|
|
||||||
tcp,
|
|
||||||
udp,
|
|
||||||
labels,
|
|
||||||
isMobile,
|
|
||||||
}: ConnectionsCardProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const accent = token.colorPrimary;
|
const accent = token.colorPrimary;
|
||||||
const udpColor = token.colorTextTertiary;
|
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 (
|
return (
|
||||||
<Card hoverable styles={{ body: { padding: 0 } }}>
|
<Card hoverable styles={{ body: { padding: 0 } }}>
|
||||||
<div className="ov-wide-head ov-wide-head-stack">
|
<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>
|
<span className="ov-legend-num">{status.udpCount.toLocaleString()}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,16 +28,10 @@ import VitalTile from './VitalTile';
|
|||||||
import ThroughputCard from './ThroughputCard';
|
import ThroughputCard from './ThroughputCard';
|
||||||
import ConnectionsCard from './ConnectionsCard';
|
import ConnectionsCard from './ConnectionsCard';
|
||||||
import SystemStrip from './SystemStrip';
|
import SystemStrip from './SystemStrip';
|
||||||
import { mean, peak, useOverviewHistory } from './useOverviewHistory';
|
|
||||||
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
import type { PanelUpdateInfo } from './PanelUpdateModal';
|
||||||
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
|
const JsonEditor = lazy(() => import('@/components/form/JsonEditor'));
|
||||||
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
const PanelUpdateModal = lazy(() => import('./PanelUpdateModal'));
|
||||||
const LogModal = lazy(() => import('./LogModal'));
|
|
||||||
const BackupModal = lazy(() => import('./BackupModal'));
|
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';
|
import './IndexPage.css';
|
||||||
|
|
||||||
export default function IndexPage() {
|
export default function IndexPage() {
|
||||||
@@ -50,7 +44,6 @@ export default function IndexPage() {
|
|||||||
setMessageInstance(messageApi);
|
setMessageInstance(messageApi);
|
||||||
}, [messageApi]);
|
}, [messageApi]);
|
||||||
|
|
||||||
const [accessLogEnable, setAccessLogEnable] = useState(false);
|
|
||||||
const [devChannelEnable, setDevChannelEnable] = useState(false);
|
const [devChannelEnable, setDevChannelEnable] = useState(false);
|
||||||
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
|
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
|
||||||
currentVersion: '',
|
currentVersion: '',
|
||||||
@@ -61,29 +54,21 @@ export default function IndexPage() {
|
|||||||
const basePath = window.X_UI_BASE_PATH || '';
|
const basePath = window.X_UI_BASE_PATH || '';
|
||||||
|
|
||||||
const [showIp, setShowIp] = useState(false);
|
const [showIp, setShowIp] = useState(false);
|
||||||
const [logsOpen, setLogsOpen] = useState(false);
|
|
||||||
const [backupOpen, setBackupOpen] = useState(false);
|
const [backupOpen, setBackupOpen] = useState(false);
|
||||||
const [panelUpdateOpen, setPanelUpdateOpen] = 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 [configTextOpen, setConfigTextOpen] = useState(false);
|
||||||
const [configText, setConfigText] = useState('');
|
const [configText, setConfigText] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
||||||
|
|
||||||
const history = useOverviewHistory(status, fetched && !fetchError);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
HttpUtil.post<{ accessLogEnable?: boolean; devChannelEnable?: boolean }>(
|
HttpUtil.post<{ devChannelEnable?: boolean }>('/panel/api/setting/defaultSettings').then(
|
||||||
'/panel/api/setting/defaultSettings',
|
(msg) => {
|
||||||
).then((msg) => {
|
if (msg?.success && msg.obj) {
|
||||||
if (msg?.success && msg.obj) {
|
setDevChannelEnable(!!msg.obj.devChannelEnable);
|
||||||
setAccessLogEnable(!!msg.obj.accessLogEnable);
|
}
|
||||||
setDevChannelEnable(!!msg.obj.devChannelEnable);
|
},
|
||||||
}
|
);
|
||||||
});
|
|
||||||
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
||||||
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
if (msg?.success && msg.obj) setPanelUpdateInfo(msg.obj);
|
||||||
});
|
});
|
||||||
@@ -182,19 +167,12 @@ export default function IndexPage() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="ov-page">
|
<div className="ov-page">
|
||||||
<OverviewActionBar
|
<OverviewActionBar
|
||||||
status={status}
|
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
accessLogEnable={accessLogEnable}
|
|
||||||
panelVersion={displayVersion}
|
panelVersion={displayVersion}
|
||||||
latestVersion={panelUpdateInfo.latestVersion}
|
latestVersion={panelUpdateInfo.latestVersion}
|
||||||
updateAvailable={panelUpdateInfo.updateAvailable}
|
updateAvailable={panelUpdateInfo.updateAvailable}
|
||||||
onOpenLogs={() => setLogsOpen(true)}
|
|
||||||
onOpenXrayLogs={() => setXrayLogsOpen(true)}
|
|
||||||
onOpenAmneziaWGLogs={() => setAmneziawgLogsOpen(true)}
|
|
||||||
onOpenConfig={openConfig}
|
onOpenConfig={openConfig}
|
||||||
onOpenBackup={() => setBackupOpen(true)}
|
onOpenBackup={() => setBackupOpen(true)}
|
||||||
onOpenSystemHistory={() => setSysHistoryOpen(true)}
|
|
||||||
onOpenXrayMetrics={() => setXrayMetricsOpen(true)}
|
|
||||||
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
|
onOpenPanelUpdate={() => setPanelUpdateOpen(true)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -214,9 +192,6 @@ export default function IndexPage() {
|
|||||||
percent={status.cpu.percent}
|
percent={status.cpu.percent}
|
||||||
statusColor={status.cpu.color}
|
statusColor={status.cpu.color}
|
||||||
detail={`${CPUFormatter.cpuCoreFormat(status.cpuCores)} / ${status.logicalPro}T · ${CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz)}`}
|
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}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
<VitalTile
|
<VitalTile
|
||||||
@@ -225,9 +200,6 @@ export default function IndexPage() {
|
|||||||
percent={status.mem.percent}
|
percent={status.mem.percent}
|
||||||
statusColor={status.mem.color}
|
statusColor={status.mem.color}
|
||||||
detail={`${SizeFormatter.sizeFormat(status.mem.current)} / ${SizeFormatter.sizeFormat(status.mem.total)}`}
|
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}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
<VitalTile
|
<VitalTile
|
||||||
@@ -236,9 +208,6 @@ export default function IndexPage() {
|
|||||||
percent={status.swap.percent}
|
percent={status.swap.percent}
|
||||||
statusColor={status.swap.color}
|
statusColor={status.swap.color}
|
||||||
detail={`${SizeFormatter.sizeFormat(status.swap.current)} / ${SizeFormatter.sizeFormat(status.swap.total)}`}
|
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}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
<VitalTile
|
<VitalTile
|
||||||
@@ -248,27 +217,13 @@ export default function IndexPage() {
|
|||||||
statusColor={status.disk.color}
|
statusColor={status.disk.color}
|
||||||
detail={`${SizeFormatter.sizeFormat(status.disk.current)} / ${SizeFormatter.sizeFormat(totalDisk)}`}
|
detail={`${SizeFormatter.sizeFormat(status.disk.current)} / ${SizeFormatter.sizeFormat(totalDisk)}`}
|
||||||
footLeft={`${t('pages.index.free')} ${SizeFormatter.sizeFormat(freeDisk)}`}
|
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}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ov-mid">
|
<div className="ov-mid">
|
||||||
<ThroughputCard
|
<ThroughputCard status={status} />
|
||||||
status={status}
|
<ConnectionsCard 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}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SystemStrip
|
<SystemStrip
|
||||||
@@ -292,9 +247,6 @@ export default function IndexPage() {
|
|||||||
onBusy={setBusy}
|
onBusy={setBusy}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</LazyMount>
|
||||||
<LazyMount when={logsOpen}>
|
|
||||||
<LogModal open={logsOpen} onClose={() => setLogsOpen(false)} />
|
|
||||||
</LazyMount>
|
|
||||||
<LazyMount when={backupOpen}>
|
<LazyMount when={backupOpen}>
|
||||||
<BackupModal
|
<BackupModal
|
||||||
open={backupOpen}
|
open={backupOpen}
|
||||||
@@ -303,22 +255,6 @@ export default function IndexPage() {
|
|||||||
onBusy={setBusy}
|
onBusy={setBusy}
|
||||||
/>
|
/>
|
||||||
</LazyMount>
|
</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}>
|
<LazyMount when={configTextOpen}>
|
||||||
<Modal
|
<Modal
|
||||||
open={configTextOpen}
|
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 type { ReactNode } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Button, Tag, Tooltip } from 'antd';
|
import { Button, Tag, Tooltip } from 'antd';
|
||||||
import {
|
import { CloudDownloadOutlined, CloudServerOutlined, ControlOutlined } from '@ant-design/icons';
|
||||||
ApiOutlined,
|
|
||||||
ArrowUpOutlined,
|
|
||||||
AreaChartOutlined,
|
|
||||||
BarsOutlined,
|
|
||||||
CloudDownloadOutlined,
|
|
||||||
CloudServerOutlined,
|
|
||||||
ControlOutlined,
|
|
||||||
FileTextOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
|
|
||||||
import { formatPanelVersion } from '@/lib/panel-version';
|
import { formatPanelVersion } from '@/lib/panel-version';
|
||||||
import type { Status } from '@/models/status';
|
|
||||||
|
|
||||||
interface OverviewActionBarProps {
|
interface OverviewActionBarProps {
|
||||||
status: Status;
|
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
accessLogEnable: boolean;
|
|
||||||
panelVersion: string;
|
panelVersion: string;
|
||||||
latestVersion: string;
|
latestVersion: string;
|
||||||
updateAvailable: boolean;
|
updateAvailable: boolean;
|
||||||
onOpenLogs: () => void;
|
|
||||||
onOpenXrayLogs: () => void;
|
|
||||||
onOpenAmneziaWGLogs: () => void;
|
|
||||||
onOpenConfig: () => void;
|
onOpenConfig: () => void;
|
||||||
onOpenBackup: () => void;
|
onOpenBackup: () => void;
|
||||||
onOpenSystemHistory: () => void;
|
|
||||||
onOpenXrayMetrics: () => void;
|
|
||||||
onOpenPanelUpdate: () => void;
|
onOpenPanelUpdate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,74 +24,30 @@ interface BarAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function OverviewActionBar({
|
export default function OverviewActionBar({
|
||||||
status,
|
|
||||||
isMobile,
|
isMobile,
|
||||||
accessLogEnable,
|
|
||||||
panelVersion,
|
panelVersion,
|
||||||
latestVersion,
|
latestVersion,
|
||||||
updateAvailable,
|
updateAvailable,
|
||||||
onOpenLogs,
|
|
||||||
onOpenXrayLogs,
|
|
||||||
onOpenAmneziaWGLogs,
|
|
||||||
onOpenConfig,
|
onOpenConfig,
|
||||||
onOpenBackup,
|
onOpenBackup,
|
||||||
onOpenSystemHistory,
|
|
||||||
onOpenXrayMetrics,
|
|
||||||
onOpenPanelUpdate,
|
onOpenPanelUpdate,
|
||||||
}: OverviewActionBarProps) {
|
}: OverviewActionBarProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const size = isMobile ? ('small' as const) : ('middle' as const);
|
const size = isMobile ? ('small' as const) : ('middle' as const);
|
||||||
|
|
||||||
const actionGroups: BarAction[][] = [
|
const actions: BarAction[] = [
|
||||||
[
|
{
|
||||||
{ key: 'logs', icon: <BarsOutlined />, text: t('pages.index.logs'), onClick: onOpenLogs },
|
key: 'config',
|
||||||
...(accessLogEnable
|
icon: <ControlOutlined />,
|
||||||
? [
|
text: t('pages.index.config'),
|
||||||
{
|
onClick: onOpenConfig,
|
||||||
key: 'accessLogs',
|
},
|
||||||
icon: <FileTextOutlined />,
|
{
|
||||||
text: t('pages.index.accessLogs'),
|
key: 'backup',
|
||||||
onClick: onOpenXrayLogs,
|
icon: <CloudServerOutlined />,
|
||||||
},
|
text: t('pages.index.backupTitle'),
|
||||||
]
|
onClick: onOpenBackup,
|
||||||
: []),
|
},
|
||||||
...(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,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -132,24 +70,19 @@ export default function OverviewActionBar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="ov-bar-actions">
|
<div className="ov-bar-actions">
|
||||||
{actionGroups.map((group, groupIndex) => (
|
{actions.map((action) => (
|
||||||
<Fragment key={group[0].key}>
|
<Button
|
||||||
{groupIndex > 0 && <span className="ov-bar-sep" />}
|
key={action.key}
|
||||||
{group.map((action) => (
|
type={action.primary ? undefined : 'text'}
|
||||||
<Button
|
color={action.primary ? 'primary' : undefined}
|
||||||
key={action.key}
|
variant={action.primary ? 'outlined' : undefined}
|
||||||
type={action.primary ? undefined : 'text'}
|
size={size}
|
||||||
color={action.primary ? 'primary' : undefined}
|
icon={action.icon}
|
||||||
variant={action.primary ? 'outlined' : undefined}
|
aria-label={action.text}
|
||||||
size={size}
|
onClick={action.onClick}
|
||||||
icon={action.icon}
|
>
|
||||||
aria-label={action.text}
|
{isMobile ? undefined : action.text}
|
||||||
onClick={action.onClick}
|
</Button>
|
||||||
>
|
|
||||||
{isMobile ? undefined : action.text}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</Fragment>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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 { useTranslation } from 'react-i18next';
|
||||||
import { Card, theme } from 'antd';
|
import { Card, theme } from 'antd';
|
||||||
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons';
|
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { SizeFormatter } from '@/utils';
|
import { SizeFormatter } from '@/utils';
|
||||||
import { Sparkline } from '@/components/viz';
|
|
||||||
import type { Status } from '@/models/status';
|
import type { Status } from '@/models/status';
|
||||||
import { mean, peak } from './useOverviewHistory';
|
|
||||||
|
|
||||||
interface ThroughputCardProps {
|
interface ThroughputCardProps {
|
||||||
status: Status;
|
status: Status;
|
||||||
up: number[];
|
|
||||||
down: number[];
|
|
||||||
labels: string[];
|
|
||||||
isMobile: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ThroughputCard({
|
export default function ThroughputCard({ status }: ThroughputCardProps) {
|
||||||
status,
|
|
||||||
up,
|
|
||||||
down,
|
|
||||||
labels,
|
|
||||||
isMobile,
|
|
||||||
}: ThroughputCardProps) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { token } = theme.useToken();
|
const { token } = theme.useToken();
|
||||||
const accent = token.colorPrimary;
|
const accent = token.colorPrimary;
|
||||||
const downColor = token.colorTextTertiary;
|
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 (
|
return (
|
||||||
<Card hoverable styles={{ body: { padding: 0 } }}>
|
<Card hoverable styles={{ body: { padding: 0 } }}>
|
||||||
<div className="ov-wide-head">
|
<div className="ov-wide-head">
|
||||||
<div>
|
<div>
|
||||||
<div className="ov-kicker">{t('pages.index.overallSpeed')}</div>
|
<div className="ov-kicker">{t('pages.index.overallSpeed')}</div>
|
||||||
<div className="ov-sub">
|
<div className="ov-sub">{t('pages.index.throughputSub')}</div>
|
||||||
{`${t('pages.index.throughputSub')} · ${t('pages.index.peak')} ${SizeFormatter.speedFormat(peak(down))}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ov-wide-legend">
|
<div className="ov-wide-legend">
|
||||||
<div className="ov-legend-label">
|
<div className="ov-legend-label">
|
||||||
@@ -59,26 +36,6 @@ export default function ThroughputCard({
|
|||||||
</div>
|
</div>
|
||||||
</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 className="ov-wide-foot">
|
||||||
<div>
|
<div>
|
||||||
<div className="ov-kicker">{t('pages.index.sent')}</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-kicker">{t('pages.index.received')}</div>
|
||||||
<div className="ov-foot-value">{SizeFormatter.sizeFormat(status.netTraffic.recv)}</div>
|
<div className="ov-foot-value">{SizeFormatter.sizeFormat(status.netTraffic.recv)}</div>
|
||||||
</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>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { Card, theme } from 'antd';
|
import { Card } from 'antd';
|
||||||
|
|
||||||
import { Sparkline } from '@/components/viz';
|
|
||||||
import { mean, peak } from './useOverviewHistory';
|
|
||||||
|
|
||||||
interface VitalTileProps {
|
interface VitalTileProps {
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
@@ -11,31 +7,11 @@ interface VitalTileProps {
|
|||||||
percent: number;
|
percent: number;
|
||||||
statusColor: string;
|
statusColor: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
footLeft: string;
|
footLeft?: string;
|
||||||
footRight: string;
|
|
||||||
data: number[];
|
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VitalTile({
|
export default function VitalTile({ icon, label, percent, detail, footLeft }: VitalTileProps) {
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card hoverable className="ov-tile" styles={{ body: { padding: 0 } }}>
|
<Card hoverable className="ov-tile" styles={{ body: { padding: 0 } }}>
|
||||||
<div className="ov-tile-head">
|
<div className="ov-tile-head">
|
||||||
@@ -50,26 +26,11 @@ export default function VitalTile({
|
|||||||
|
|
||||||
<div className="ov-tile-detail">{detail}</div>
|
<div className="ov-tile-detail">{detail}</div>
|
||||||
|
|
||||||
<div className="ov-tile-foot">
|
{footLeft ? (
|
||||||
<span>{footLeft}</span>
|
<div className="ov-tile-foot">
|
||||||
<span>{footRight}</span>
|
<span>{footLeft}</span>
|
||||||
</div>
|
</div>
|
||||||
|
) : null}
|
||||||
<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>
|
|
||||||
</Card>
|
</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,
|
id: 0,
|
||||||
name: '',
|
name: '',
|
||||||
remark: '',
|
remark: '',
|
||||||
|
nodeType: 'traditional',
|
||||||
scheme: 'https',
|
scheme: 'https',
|
||||||
address: '',
|
address: '',
|
||||||
port: 2053,
|
port: 2053,
|
||||||
@@ -76,9 +77,14 @@ export default function NodeFormModal({
|
|||||||
const [fetchingInbounds, setFetchingInbounds] = useState(false);
|
const [fetchingInbounds, setFetchingInbounds] = useState(false);
|
||||||
const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
|
const [inboundOptions, setInboundOptions] = useState<RemoteInboundOption[]>([]);
|
||||||
const [testResult, setTestResult] = useState<ProbeResult | null>(null);
|
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 scheme = useWatch({ control: methods.control, name: 'scheme' }) ?? 'https';
|
||||||
const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify';
|
const tlsVerifyMode = useWatch({ control: methods.control, name: 'tlsVerifyMode' }) ?? 'verify';
|
||||||
const inboundSyncMode = useWatch({ control: methods.control, name: 'inboundSyncMode' }) ?? 'all';
|
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 });
|
const { data: outboundGroups } = useOutboundTagGroups({ excludeBlackhole: true });
|
||||||
|
|
||||||
// Outbounds and balancers share one picker (like the panel-outbound selector);
|
// 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> {
|
function buildPayload(values: NodeFormValues): Partial<NodeRecord> {
|
||||||
const token = values.apiToken.trim();
|
const token = values.apiToken.trim();
|
||||||
|
const agentMode = values.nodeType === 'agent';
|
||||||
const payload: Partial<NodeRecord> = {
|
const payload: Partial<NodeRecord> = {
|
||||||
id: values.id || 0,
|
id: values.id || 0,
|
||||||
name: values.name.trim(),
|
name: values.name.trim(),
|
||||||
remark: values.remark?.trim() || '',
|
remark: values.remark?.trim() || '',
|
||||||
|
nodeType: values.nodeType,
|
||||||
scheme: values.scheme,
|
scheme: values.scheme,
|
||||||
address: values.address.trim(),
|
// Agent nodes have no panel address; the backend ignores these.
|
||||||
port: values.port,
|
address: agentMode ? 'agent' : values.address.trim(),
|
||||||
|
port: agentMode ? 2053 : values.port,
|
||||||
basePath: values.basePath.trim() || '/',
|
basePath: values.basePath.trim() || '/',
|
||||||
enable: values.enable,
|
enable: values.enable,
|
||||||
allowPrivateAddress: values.allowPrivateAddress,
|
allowPrivateAddress: values.allowPrivateAddress,
|
||||||
@@ -217,18 +226,34 @@ export default function NodeFormModal({
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const payload = buildPayload(result.data);
|
const payload = buildPayload(result.data);
|
||||||
const test = await testConnection(payload);
|
const agentMode = result.data.nodeType === 'agent';
|
||||||
const probe = test?.success ? test.obj : null;
|
// Agent nodes have no panel to probe; skip the connection test.
|
||||||
if (!probe || probe.status !== 'online') {
|
if (!agentMode) {
|
||||||
setTestResult(
|
const test = await testConnection(payload);
|
||||||
probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') },
|
const probe = test?.success ? test.obj : null;
|
||||||
);
|
if (!probe || probe.status !== 'online') {
|
||||||
return;
|
setTestResult(
|
||||||
|
probe ?? { status: 'offline', error: test?.msg || t('pages.nodes.connectionFailed') },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTestResult(probe);
|
||||||
}
|
}
|
||||||
setTestResult(probe);
|
|
||||||
const msg = await save(payload);
|
const msg = await save(payload);
|
||||||
if (msg?.success) {
|
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 {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
@@ -255,6 +280,27 @@ export default function NodeFormModal({
|
|||||||
>
|
>
|
||||||
<FormProvider {...methods}>
|
<FormProvider {...methods}>
|
||||||
<Form layout="vertical">
|
<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}>
|
<Row gutter={16}>
|
||||||
<Col xs={24} md={12}>
|
<Col xs={24} md={12}>
|
||||||
<FormField
|
<FormField
|
||||||
@@ -272,80 +318,97 @@ export default function NodeFormModal({
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Row gutter={16}>
|
{!isAgent && (
|
||||||
<Col xs={24} md={6}>
|
<Row gutter={16}>
|
||||||
<FormField
|
<Col xs={24} md={6}>
|
||||||
label={t('pages.nodes.scheme')}
|
<FormField
|
||||||
name="scheme"
|
label={t('pages.nodes.scheme')}
|
||||||
onAfterChange={(value) => {
|
name="scheme"
|
||||||
if (value === 'http') methods.setValue('tlsVerifyMode', 'skip');
|
onAfterChange={(value) => {
|
||||||
}}
|
if (value === 'http') methods.setValue('tlsVerifyMode', 'skip');
|
||||||
>
|
}}
|
||||||
<Select
|
>
|
||||||
options={[
|
<Select
|
||||||
{ value: 'https', label: 'https' },
|
options={[
|
||||||
{ value: 'http', label: 'http' },
|
{ value: 'https', label: 'https' },
|
||||||
]}
|
{ value: 'http', label: 'http' },
|
||||||
/>
|
]}
|
||||||
</FormField>
|
/>
|
||||||
</Col>
|
</FormField>
|
||||||
<Col xs={24} md={12}>
|
</Col>
|
||||||
<FormField
|
<Col xs={24} md={12}>
|
||||||
label={t('pages.nodes.address')}
|
<FormField
|
||||||
name="address"
|
label={t('pages.nodes.address')}
|
||||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.address) }}
|
name="address"
|
||||||
>
|
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.address) }}
|
||||||
<Input placeholder={t('pages.nodes.addressPlaceholder')} />
|
>
|
||||||
</FormField>
|
<Input placeholder={t('pages.nodes.addressPlaceholder')} />
|
||||||
</Col>
|
</FormField>
|
||||||
<Col xs={24} md={6}>
|
</Col>
|
||||||
<FormField
|
<Col xs={24} md={6}>
|
||||||
label={t('pages.nodes.port')}
|
<FormField
|
||||||
name="port"
|
label={t('pages.nodes.port')}
|
||||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.port) }}
|
name="port"
|
||||||
>
|
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.port) }}
|
||||||
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
>
|
||||||
</FormField>
|
<InputNumber min={1} max={65535} style={{ width: '100%' }} />
|
||||||
</Col>
|
</FormField>
|
||||||
</Row>
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
<Row gutter={16}>
|
{!isAgent && (
|
||||||
<Col xs={24} md={12}>
|
<Row gutter={16}>
|
||||||
<FormField label={t('pages.nodes.basePath')} name="basePath">
|
<Col xs={24} md={12}>
|
||||||
<Input placeholder="/" />
|
<FormField label={t('pages.nodes.basePath')} name="basePath">
|
||||||
</FormField>
|
<Input placeholder="/" />
|
||||||
</Col>
|
</FormField>
|
||||||
<Col xs={24} md={12}>
|
</Col>
|
||||||
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
<Col xs={24} md={12}>
|
||||||
<Switch />
|
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||||
</FormField>
|
<Switch />
|
||||||
</Col>
|
</FormField>
|
||||||
</Row>
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
{isAgent && (
|
||||||
|
<Row gutter={16}>
|
||||||
|
<Col xs={24}>
|
||||||
|
<FormField label={t('pages.nodes.enable')} name="enable" valueProp="checked">
|
||||||
|
<Switch />
|
||||||
|
</FormField>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
{!isAgent && (
|
||||||
label={t('pages.nodes.allowPrivateAddress')}
|
<FormField
|
||||||
name="allowPrivateAddress"
|
label={t('pages.nodes.allowPrivateAddress')}
|
||||||
valueProp="checked"
|
name="allowPrivateAddress"
|
||||||
tooltip={t('pages.nodes.allowPrivateAddressHint')}
|
valueProp="checked"
|
||||||
>
|
tooltip={t('pages.nodes.allowPrivateAddressHint')}
|
||||||
<Switch />
|
>
|
||||||
</FormField>
|
<Switch />
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
{!isAgent && (
|
||||||
label={t('pages.nodes.tlsVerifyMode')}
|
<FormField
|
||||||
name="tlsVerifyMode"
|
label={t('pages.nodes.tlsVerifyMode')}
|
||||||
tooltip={t('pages.nodes.tlsVerifyModeHint')}
|
name="tlsVerifyMode"
|
||||||
>
|
tooltip={t('pages.nodes.tlsVerifyModeHint')}
|
||||||
<Select
|
>
|
||||||
disabled={scheme === 'http'}
|
<Select
|
||||||
options={[
|
disabled={scheme === 'http'}
|
||||||
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
|
options={[
|
||||||
{ value: 'pin', label: t('pages.nodes.tlsPin') },
|
{ value: 'verify', label: t('pages.nodes.tlsVerify') },
|
||||||
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
|
{ value: 'pin', label: t('pages.nodes.tlsPin') },
|
||||||
{ value: 'mtls', label: t('pages.nodes.tlsMtls') },
|
{ value: 'skip', label: t('pages.nodes.tlsSkip') },
|
||||||
]}
|
{ value: 'mtls', label: t('pages.nodes.tlsMtls') },
|
||||||
/>
|
]}
|
||||||
</FormField>
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
{tlsVerifyMode === 'skip' && (
|
{tlsVerifyMode === 'skip' && (
|
||||||
<Alert
|
<Alert
|
||||||
@@ -380,21 +443,23 @@ export default function NodeFormModal({
|
|||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<FormField
|
{!isAgent && (
|
||||||
label={t('pages.nodes.apiToken')}
|
<FormField
|
||||||
name="apiToken"
|
label={t('pages.nodes.apiToken')}
|
||||||
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
|
name="apiToken"
|
||||||
tooltip={t('pages.nodes.apiTokenHint')}
|
rules={{ validate: rhfZodValidate(NodeFormSchema.shape.apiToken) }}
|
||||||
extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined}
|
tooltip={t('pages.nodes.apiTokenHint')}
|
||||||
>
|
extra={editingWithToken ? t('pages.nodes.apiTokenKeepHint') : undefined}
|
||||||
<Input.Password
|
>
|
||||||
placeholder={
|
<Input.Password
|
||||||
editingWithToken
|
placeholder={
|
||||||
? t('pages.nodes.apiTokenKeepHint')
|
editingWithToken
|
||||||
: t('pages.nodes.apiTokenPlaceholder')
|
? t('pages.nodes.apiTokenKeepHint')
|
||||||
}
|
: t('pages.nodes.apiTokenPlaceholder')
|
||||||
/>
|
}
|
||||||
</FormField>
|
/>
|
||||||
|
</FormField>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
label={t('pages.nodes.outboundTag')}
|
label={t('pages.nodes.outboundTag')}
|
||||||
@@ -455,35 +520,92 @@ export default function NodeFormModal({
|
|||||||
</FormField>
|
</FormField>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="test-row">
|
{!isAgent && (
|
||||||
<Button type="default" loading={testing} onClick={onTest}>
|
<div className="test-row">
|
||||||
{t('pages.nodes.testConnection')}
|
<Button type="default" loading={testing} onClick={onTest}>
|
||||||
</Button>
|
{t('pages.nodes.testConnection')}
|
||||||
{testResult && (
|
</Button>
|
||||||
<div className="test-result">
|
{testResult && (
|
||||||
{testResult.status === 'online' ? (
|
<div className="test-result">
|
||||||
<Alert
|
{testResult.status === 'online' ? (
|
||||||
type="success"
|
<Alert
|
||||||
showIcon
|
type="success"
|
||||||
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
|
showIcon
|
||||||
description={
|
title={t('pages.nodes.connectionOk', { ms: testResult.latencyMs })}
|
||||||
testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined
|
description={
|
||||||
}
|
testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined
|
||||||
/>
|
}
|
||||||
) : (
|
/>
|
||||||
<Alert
|
) : (
|
||||||
type="error"
|
<Alert
|
||||||
showIcon
|
type="error"
|
||||||
title={t('pages.nodes.connectionFailed')}
|
showIcon
|
||||||
description={testResult.error}
|
title={t('pages.nodes.connectionFailed')}
|
||||||
/>
|
description={testResult.error}
|
||||||
)}
|
/>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</FormProvider>
|
</FormProvider>
|
||||||
</Modal>
|
</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')}
|
description={t('pages.settings.subPortDesc')}
|
||||||
>
|
>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
value={allSetting.subPort}
|
value={allSetting.subPort || null}
|
||||||
min={1}
|
min={0}
|
||||||
max={65535}
|
max={65535}
|
||||||
|
placeholder="2096"
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
onChange={onNumber((v) => updateSetting({ subPort: v }))}
|
onChange={onNumber((v) => updateSetting({ subPort: v }), { onClear: 0 })}
|
||||||
/>
|
/>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ export const NodeRecordSchema = z
|
|||||||
guid: z.string().optional(),
|
guid: z.string().optional(),
|
||||||
parentGuid: z.string().optional(),
|
parentGuid: z.string().optional(),
|
||||||
transitive: z.boolean().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();
|
.loose();
|
||||||
|
|
||||||
@@ -66,12 +69,19 @@ export const NodeFormSchema = z
|
|||||||
id: z.number().optional(),
|
id: z.number().optional(),
|
||||||
name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
name: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
||||||
remark: z.string().optional(),
|
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']),
|
scheme: z.enum(['http', 'https']),
|
||||||
address: z.string().trim().min(1, 'pages.nodes.toasts.fillRequired'),
|
// For agent nodes the panel never dials the node (agent dials out),
|
||||||
port: z.number().int().min(1).max(65535),
|
// 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(),
|
basePath: z.string(),
|
||||||
// mTLS nodes authenticate via the client certificate, so the token is optional
|
// mTLS nodes authenticate via the client certificate, so the token is optional
|
||||||
// there; every other verify mode still requires one (matches remote.do()).
|
// 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(),
|
apiToken: z.string().trim(),
|
||||||
hasStoredToken: z.boolean().optional().default(false),
|
hasStoredToken: z.boolean().optional().default(false),
|
||||||
enable: z.boolean(),
|
enable: z.boolean(),
|
||||||
@@ -88,6 +98,21 @@ export const NodeFormSchema = z
|
|||||||
outboundTag: z.string().optional(),
|
outboundTag: z.string().optional(),
|
||||||
})
|
})
|
||||||
.superRefine((val, ctx) => {
|
.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) {
|
if (val.tlsVerifyMode !== 'mtls' && val.apiToken.length === 0 && !val.hasStoredToken) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: 'custom',
|
code: 'custom',
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ export const AllSettingSchema = z
|
|||||||
subIncyEnableRouting: z.boolean().optional(),
|
subIncyEnableRouting: z.boolean().optional(),
|
||||||
subIncyRoutingRules: z.string().optional(),
|
subIncyRoutingRules: z.string().optional(),
|
||||||
subListen: 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(),
|
subPath: absolutePath.optional(),
|
||||||
subJsonPath: absolutePath.optional(),
|
subJsonPath: absolutePath.optional(),
|
||||||
subClashEnable: z.boolean().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);
|
handler(NaN);
|
||||||
expect(apply).not.toHaveBeenCalled();
|
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.
|
* input snaps back on blur — while real numbers pass through unchanged.
|
||||||
* Number-only by design: not for `stringMode` inputs, whose whole point is
|
* Number-only by design: not for `stringMode` inputs, whose whole point is
|
||||||
* to avoid the IEEE-754 round trip this signature would force.
|
* 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(
|
export function onNumber(
|
||||||
apply: (value: number) => void,
|
apply: (value: number) => void,
|
||||||
|
options?: { onClear?: number },
|
||||||
): (value: number | null | undefined) => void {
|
): (value: number | null | undefined) => void {
|
||||||
return (value) => {
|
return (value) => {
|
||||||
if (typeof value !== 'number' || !Number.isFinite(value)) return;
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
apply(value);
|
apply(value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (options?.onClear !== undefined) apply(options.onClear);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,17 @@ var name string
|
|||||||
// CI per-commit (dev channel) builds; see .github/workflows/release.yml. They
|
// CI per-commit (dev channel) builds; see .github/workflows/release.yml. They
|
||||||
// stay empty for a plain `go build` and for stable tagged releases, which is how
|
// stay empty for a plain `go build` and for stable tagged releases, which is how
|
||||||
// IsDevBuild tells a rolling dev build apart from a stable/local one.
|
// IsDevBuild tells a rolling dev build apart from a stable/local one.
|
||||||
|
//
|
||||||
|
// buildVersion is injected the same way by this fork's release builds
|
||||||
|
// (e.g. "3.7.0-agent.1", matching the Gitea release tag) so the binary reports
|
||||||
|
// the fork release it was built from — the embedded version file always names
|
||||||
|
// the upstream base release, which made the update check compare "3.7.0"
|
||||||
|
// against the fork's "v3.7.0-agent.1" tag and report a phantom update forever.
|
||||||
|
// It stays empty for a plain `go build`, which falls back to the embedded file.
|
||||||
var (
|
var (
|
||||||
buildCommit string
|
buildCommit string
|
||||||
buildDate string
|
buildDate string
|
||||||
|
buildVersion string
|
||||||
)
|
)
|
||||||
|
|
||||||
// LogLevel represents the logging level for the application.
|
// LogLevel represents the logging level for the application.
|
||||||
@@ -41,11 +49,19 @@ const (
|
|||||||
Error LogLevel = "error"
|
Error LogLevel = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetBaseVersion returns the raw embedded release version of the 3x-ui panel
|
// GetBaseVersion returns the release version of the 3x-ui panel
|
||||||
// (e.g. "3.4.0"). This is the panel's own version, not the Xray version. For the
|
// (e.g. "3.4.0"). This is the panel's own version, not the Xray version. For the
|
||||||
// version a panel advertises/displays (which adds a "dev+<sha>" label on dev
|
// version a panel advertises/displays (which adds a "dev+<sha>" label on dev
|
||||||
// builds), use GetPanelVersion.
|
// builds), use GetPanelVersion.
|
||||||
|
//
|
||||||
|
// A fork release build injects buildVersion via `-ldflags -X`, which wins over
|
||||||
|
// the embedded version file; every version consumer (UI display, update check,
|
||||||
|
// panel-version advertisement to a master) reads through here, so the injected
|
||||||
|
// value is the single source of truth for the running binary.
|
||||||
func GetBaseVersion() string {
|
func GetBaseVersion() string {
|
||||||
|
if v := strings.TrimSpace(buildVersion); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
return strings.TrimSpace(version)
|
return strings.TrimSpace(version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,34 @@ func TestGetPanelVersion(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGetBaseVersionBuildVersionOverride(t *testing.T) {
|
||||||
|
orig := buildVersion
|
||||||
|
t.Cleanup(func() { buildVersion = orig })
|
||||||
|
|
||||||
|
// No injection (plain `go build`): the embedded version file wins.
|
||||||
|
buildVersion = ""
|
||||||
|
embedded := GetBaseVersion()
|
||||||
|
if embedded == "" {
|
||||||
|
t.Fatal("embedded version file must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Injected fork release version wins over the embedded file and flows
|
||||||
|
// into GetPanelVersion untouched (stable channel, no dev label).
|
||||||
|
buildVersion = "3.7.0-agent.1"
|
||||||
|
if got := GetBaseVersion(); got != "3.7.0-agent.1" {
|
||||||
|
t.Fatalf("injected build: GetBaseVersion = %q, want %q", got, "3.7.0-agent.1")
|
||||||
|
}
|
||||||
|
if got := GetPanelVersion(); got != "3.7.0-agent.1" {
|
||||||
|
t.Fatalf("injected build: GetPanelVersion = %q, want %q", got, "3.7.0-agent.1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Whitespace-only injection counts as unset.
|
||||||
|
buildVersion = " "
|
||||||
|
if got := GetBaseVersion(); got != embedded {
|
||||||
|
t.Fatalf("blank injection: GetBaseVersion = %q, want embedded %q", got, embedded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestGetPortOverride(t *testing.T) {
|
func TestGetPortOverride(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
+50
-79
@@ -1,5 +1,7 @@
|
|||||||
// Package logger provides logging functionality for the 3x-ui panel with
|
// Package logger provides logging functionality for the 3x-ui panel with
|
||||||
// dual-backend logging (console/syslog and file) and buffered log storage for web UI.
|
// dual-backend logging (console/syslog and file). The in-memory log buffer
|
||||||
|
// that fed the web UI's log viewer was removed together with that feature;
|
||||||
|
// logs live on disk (rotating file under the log folder) and in syslog.
|
||||||
package logger
|
package logger
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -8,7 +10,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/op/go-logging"
|
"github.com/op/go-logging"
|
||||||
|
|
||||||
@@ -18,9 +19,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
maxLogBufferSize = 10240 // Maximum log entries kept in memory
|
logFileName = "3xui.log" // Log file name
|
||||||
logFileName = "3xui.log" // Log file name
|
timeFormat = "2006/01/02 15:04:05" // Log timestamp format
|
||||||
timeFormat = "2006/01/02 15:04:05" // Log timestamp format
|
|
||||||
|
|
||||||
// On-disk rotation limits — single file capped, old segments pruned automatically.
|
// On-disk rotation limits — single file capped, old segments pruned automatically.
|
||||||
maxLogFileMB = 10 // rotate active log when larger than this
|
maxLogFileMB = 10 // rotate active log when larger than this
|
||||||
@@ -35,16 +35,33 @@ var (
|
|||||||
logger = logging.MustGetLogger("x-ui")
|
logger = logging.MustGetLogger("x-ui")
|
||||||
fileRotate *lumberjack.Logger // nil when file backend disabled
|
fileRotate *lumberjack.Logger // nil when file backend disabled
|
||||||
|
|
||||||
// logBuffer maintains recent log entries in memory for web UI retrieval;
|
// logHook, when set, additionally receives every log entry. Production
|
||||||
// logBufferMu guards it — written from many goroutines, read by the web UI.
|
// never sets it; it exists so tests can assert on emitted logs now that
|
||||||
logBufferMu sync.Mutex
|
// the web UI's in-memory buffer is gone.
|
||||||
logBuffer []struct {
|
logHookMu sync.RWMutex
|
||||||
time string
|
logHook func(level, msg string)
|
||||||
level logging.Level
|
|
||||||
log string
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// SetLogHook installs a sink receiving every log entry (level name and
|
||||||
|
// formatted message) in addition to the normal backends, and returns the
|
||||||
|
// previously installed hook (nil in production). Passing nil clears it.
|
||||||
|
func SetLogHook(h func(level, msg string)) func(level, msg string) {
|
||||||
|
logHookMu.Lock()
|
||||||
|
defer logHookMu.Unlock()
|
||||||
|
prev := logHook
|
||||||
|
logHook = h
|
||||||
|
return prev
|
||||||
|
}
|
||||||
|
|
||||||
|
func emitLogHook(level, msg string) {
|
||||||
|
logHookMu.RLock()
|
||||||
|
h := logHook
|
||||||
|
logHookMu.RUnlock()
|
||||||
|
if h != nil {
|
||||||
|
h(level, msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// InitLogger initializes dual logging backends: console/syslog and file.
|
// InitLogger initializes dual logging backends: console/syslog and file.
|
||||||
// Console logging uses the specified level, file logging always uses DEBUG level.
|
// Console logging uses the specified level, file logging always uses DEBUG level.
|
||||||
func InitLogger(level logging.Level) {
|
func InitLogger(level logging.Level) {
|
||||||
@@ -135,108 +152,62 @@ func CloseLogger() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debug logs a debug message and adds it to the log buffer.
|
// Debug logs a debug message and emits it to the log hook.
|
||||||
func Debug(args ...any) {
|
func Debug(args ...any) {
|
||||||
logger.Debug(args...)
|
logger.Debug(args...)
|
||||||
addToBuffer("DEBUG", fmt.Sprint(args...))
|
emitLogHook("DEBUG", fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Debugf logs a formatted debug message and adds it to the log buffer.
|
// Debugf logs a formatted debug message and emits it to the log hook.
|
||||||
func Debugf(format string, args ...any) {
|
func Debugf(format string, args ...any) {
|
||||||
logger.Debugf(format, args...)
|
logger.Debugf(format, args...)
|
||||||
addToBuffer("DEBUG", fmt.Sprintf(format, args...))
|
emitLogHook("DEBUG", fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Info logs an info message and adds it to the log buffer.
|
// Info logs an info message and emits it to the log hook.
|
||||||
func Info(args ...any) {
|
func Info(args ...any) {
|
||||||
logger.Info(args...)
|
logger.Info(args...)
|
||||||
addToBuffer("INFO", fmt.Sprint(args...))
|
emitLogHook("INFO", fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infof logs a formatted info message and adds it to the log buffer.
|
// Infof logs a formatted info message and emits it to the log hook.
|
||||||
func Infof(format string, args ...any) {
|
func Infof(format string, args ...any) {
|
||||||
logger.Infof(format, args...)
|
logger.Infof(format, args...)
|
||||||
addToBuffer("INFO", fmt.Sprintf(format, args...))
|
emitLogHook("INFO", fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notice logs a notice message and adds it to the log buffer.
|
// Notice logs a notice message and emits it to the log hook.
|
||||||
func Notice(args ...any) {
|
func Notice(args ...any) {
|
||||||
logger.Notice(args...)
|
logger.Notice(args...)
|
||||||
addToBuffer("NOTICE", fmt.Sprint(args...))
|
emitLogHook("NOTICE", fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Noticef logs a formatted notice message and adds it to the log buffer.
|
// Noticef logs a formatted notice message and emits it to the log hook.
|
||||||
func Noticef(format string, args ...any) {
|
func Noticef(format string, args ...any) {
|
||||||
logger.Noticef(format, args...)
|
logger.Noticef(format, args...)
|
||||||
addToBuffer("NOTICE", fmt.Sprintf(format, args...))
|
emitLogHook("NOTICE", fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warning logs a warning message and adds it to the log buffer.
|
// Warning logs a warning message and emits it to the log hook.
|
||||||
func Warning(args ...any) {
|
func Warning(args ...any) {
|
||||||
logger.Warning(args...)
|
logger.Warning(args...)
|
||||||
addToBuffer("WARNING", fmt.Sprint(args...))
|
emitLogHook("WARNING", fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Warningf logs a formatted warning message and adds it to the log buffer.
|
// Warningf logs a formatted warning message and emits it to the log hook.
|
||||||
func Warningf(format string, args ...any) {
|
func Warningf(format string, args ...any) {
|
||||||
logger.Warningf(format, args...)
|
logger.Warningf(format, args...)
|
||||||
addToBuffer("WARNING", fmt.Sprintf(format, args...))
|
emitLogHook("WARNING", fmt.Sprintf(format, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error logs an error message and adds it to the log buffer.
|
// Error logs an error message and emits it to the log hook.
|
||||||
func Error(args ...any) {
|
func Error(args ...any) {
|
||||||
logger.Error(args...)
|
logger.Error(args...)
|
||||||
addToBuffer("ERROR", fmt.Sprint(args...))
|
emitLogHook("ERROR", fmt.Sprint(args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Errorf logs a formatted error message and adds it to the log buffer.
|
// Errorf logs a formatted error message and emits it to the log hook.
|
||||||
func Errorf(format string, args ...any) {
|
func Errorf(format string, args ...any) {
|
||||||
logger.Errorf(format, args...)
|
logger.Errorf(format, args...)
|
||||||
addToBuffer("ERROR", fmt.Sprintf(format, args...))
|
emitLogHook("ERROR", fmt.Sprintf(format, args...))
|
||||||
}
|
|
||||||
|
|
||||||
// addToBuffer adds a log entry to the in-memory ring buffer for web UI retrieval.
|
|
||||||
func addToBuffer(level string, newLog string) {
|
|
||||||
t := time.Now()
|
|
||||||
logBufferMu.Lock()
|
|
||||||
defer logBufferMu.Unlock()
|
|
||||||
if len(logBuffer) >= maxLogBufferSize {
|
|
||||||
logBuffer = logBuffer[1:]
|
|
||||||
}
|
|
||||||
|
|
||||||
logLevel, _ := logging.LogLevel(level)
|
|
||||||
logBuffer = append(logBuffer, struct {
|
|
||||||
time string
|
|
||||||
level logging.Level
|
|
||||||
log string
|
|
||||||
}{
|
|
||||||
time: t.Format(timeFormat),
|
|
||||||
level: logLevel,
|
|
||||||
log: newLog,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetLogs retrieves up to c log entries from the buffer that are at or below the specified level.
|
|
||||||
func GetLogs(c int, level string) []string {
|
|
||||||
var output []string
|
|
||||||
logLevel, _ := logging.LogLevel(level)
|
|
||||||
|
|
||||||
// Snapshot (copy) under the lock, then filter/format unlocked: a UI log fetch
|
|
||||||
// must not block addToBuffer — and thus all logging — for the formatting loop.
|
|
||||||
// A copy (not a reslice) is required, since addToBuffer can append in place.
|
|
||||||
logBufferMu.Lock()
|
|
||||||
snapshot := make([]struct {
|
|
||||||
time string
|
|
||||||
level logging.Level
|
|
||||||
log string
|
|
||||||
}, len(logBuffer))
|
|
||||||
copy(snapshot, logBuffer)
|
|
||||||
logBufferMu.Unlock()
|
|
||||||
|
|
||||||
for i := len(snapshot) - 1; i >= 0 && len(output) < c; i-- {
|
|
||||||
if snapshot[i].level <= logLevel {
|
|
||||||
output = append(output, fmt.Sprintf("%s %s - %s", snapshot[i].time, snapshot[i].level, snapshot[i].log))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return output
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,29 +2,47 @@ package logger
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestGetLogs_ReturnsAtMostC guards the documented "up to c entries" contract.
|
// TestLogHookReceivesEntries replaces the old in-memory buffer tests: the
|
||||||
// The loop condition must cap output at c (ERROR entries are queried at "debug"
|
// buffer (and its GetLogs accessor) was removed with the web log viewer, and
|
||||||
// level so the level filter passes all of them, isolating the count).
|
// the hook is now the only side channel tests assert on.
|
||||||
func TestGetLogs_ReturnsAtMostC(t *testing.T) {
|
func TestLogHookReceivesEntries(t *testing.T) {
|
||||||
logBufferMu.Lock()
|
var mu sync.Mutex
|
||||||
logBuffer = nil
|
var got []string
|
||||||
logBufferMu.Unlock()
|
prev := SetLogHook(func(level, msg string) {
|
||||||
for i := range 5 {
|
mu.Lock()
|
||||||
addToBuffer("ERROR", fmt.Sprintf("m%d", i))
|
defer mu.Unlock()
|
||||||
}
|
got = append(got, level+" "+msg)
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { SetLogHook(prev) })
|
||||||
|
|
||||||
cases := []struct{ c, want int }{
|
Debug("d1")
|
||||||
{0, 0},
|
Infof("i%d", 2)
|
||||||
{2, 2},
|
Warning("w3")
|
||||||
{5, 5},
|
Errorf("e%s", "4")
|
||||||
{10, 5}, // capped at what's available
|
|
||||||
|
want := []string{"DEBUG d1", "INFO i2", "WARNING w3", "ERROR e4"}
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("hook captured %v, want %v", got, want)
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for i := range want {
|
||||||
if got := GetLogs(tc.c, "debug"); len(got) != tc.want {
|
if got[i] != want[i] {
|
||||||
t.Errorf("GetLogs(%d) returned %d entries, want %d", tc.c, len(got), tc.want)
|
t.Fatalf("hook entry %d = %q, want %q", i, got[i], want[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLogHookUnsetIsSilent guards the production path: with no hook
|
||||||
|
// installed, logging must simply not panic.
|
||||||
|
func TestLogHookUnsetIsSilent(t *testing.T) {
|
||||||
|
prev := SetLogHook(nil)
|
||||||
|
t.Cleanup(func() { SetLogHook(prev) })
|
||||||
|
for i := range 3 {
|
||||||
|
Info(fmt.Sprintf("m%d", i))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
@@ -20,6 +21,22 @@ func TestExtraSalamanderKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// captureLogLines installs a logger hook collecting "LEVEL message" lines
|
||||||
|
// for the rest of the test. It replaces reads of the in-memory log buffer,
|
||||||
|
// which was removed together with the web log viewer.
|
||||||
|
func captureLogLines(t *testing.T) *[]string {
|
||||||
|
t.Helper()
|
||||||
|
var mu sync.Mutex
|
||||||
|
lines := &[]string{}
|
||||||
|
prev := logger.SetLogHook(func(level, msg string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
*lines = append(*lines, level+" "+msg)
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { logger.SetLogHook(prev) })
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
func TestGenHysteriaLinkWarnsOnceForUnsupportedSalamanderSettings(t *testing.T) {
|
func TestGenHysteriaLinkWarnsOnceForUnsupportedSalamanderSettings(t *testing.T) {
|
||||||
makeInbound := func(id int, settings string) *model.Inbound {
|
makeInbound := func(id int, settings string) *model.Inbound {
|
||||||
return &model.Inbound{
|
return &model.Inbound{
|
||||||
@@ -28,10 +45,11 @@ func TestGenHysteriaLinkWarnsOnceForUnsupportedSalamanderSettings(t *testing.T)
|
|||||||
StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":` + settings + `}]}}`,
|
StreamSettings: `{"security":"tls","finalmask":{"udp":[{"type":"salamander","settings":` + settings + `}]}}`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
logLines := captureLogLines(t)
|
||||||
countWarnings := func(id int) int {
|
countWarnings := func(id int) int {
|
||||||
needle := "inbound " + strconv.Itoa(id) + ": salamander settings"
|
needle := "inbound " + strconv.Itoa(id) + ": salamander settings"
|
||||||
count := 0
|
count := 0
|
||||||
for _, line := range logger.GetLogs(100, "warning") {
|
for _, line := range *logLines {
|
||||||
if strings.Contains(line, needle) {
|
if strings.Contains(line, needle) {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -299,6 +299,13 @@ func (s *Server) Start() (err error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// subPort 0 only means "no port in generated links" (see
|
||||||
|
// SettingService.BuildSubURIBase); the listener itself always binds a
|
||||||
|
// real port — fall back to the setting's default so an empty port can
|
||||||
|
// never turn into a ":0" bind on a random OS-assigned port.
|
||||||
|
if port <= 0 {
|
||||||
|
port = 2096
|
||||||
|
}
|
||||||
|
|
||||||
listenAddr := net.JoinHostPort(listen, strconv.Itoa(port))
|
listenAddr := net.JoinHostPort(listen, strconv.Itoa(port))
|
||||||
listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", listenAddr)
|
listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", listenAddr)
|
||||||
|
|||||||
@@ -73,15 +73,9 @@ func (a *APIController) checkAPIAuth(c *gin.Context) {
|
|||||||
// monitorScopeAllow exposes only status/metrics routes without sensitive data.
|
// monitorScopeAllow exposes only status/metrics routes without sensitive data.
|
||||||
// Keys are route patterns relative to /panel/api.
|
// Keys are route patterns relative to /panel/api.
|
||||||
var monitorScopeAllow = map[string]struct{}{
|
var monitorScopeAllow = map[string]struct{}{
|
||||||
"/server/status": {},
|
"/server/status": {},
|
||||||
"/server/cpuHistory/:bucket": {},
|
"/server/getPanelUpdateInfo": {},
|
||||||
"/server/history/:metric/:bucket": {},
|
"/nodes/history/:id/:metric/:bucket": {},
|
||||||
"/server/xrayMetricsState": {},
|
|
||||||
"/server/xrayMetricsHistory/:metric/:bucket": {},
|
|
||||||
"/server/xrayObservatory": {},
|
|
||||||
"/server/xrayObservatoryHistory/:tag/:bucket": {},
|
|
||||||
"/server/getPanelUpdateInfo": {},
|
|
||||||
"/nodes/history/:id/:metric/:bucket": {},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// nodeSyncScopeAllow is the node-sync route/method allowlist relative to
|
// nodeSyncScopeAllow is the node-sync route/method allowlist relative to
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ func (a *NodeController) add(c *gin.Context) {
|
|||||||
// For agent nodes, issue an agent token bound to the new node and
|
// For agent nodes, issue an agent token bound to the new node and
|
||||||
// return it so the UI can display it (shown only once).
|
// return it so the UI can display it (shown only once).
|
||||||
if isAgent {
|
if isAgent {
|
||||||
|
view.NodeType = "agent"
|
||||||
nodeID := view.Id
|
nodeID := view.Id
|
||||||
token, _, err := issueAgentToken(&nodeID, n.Name)
|
token, _, err := issueAgentToken(&nodeID, n.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -200,7 +201,10 @@ func (a *NodeController) update(c *gin.Context) {
|
|||||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if n.OutboundTag == "" && old.OutboundTag == "" && (!n.ClearApiToken || n.Enable) {
|
// Agent nodes have no panel to probe and no panel API token; editing one
|
||||||
|
// must skip the reachability check exactly like creation does.
|
||||||
|
isAgent := n.NodeType == "agent" || a.nodeService.IsAgentManaged(id)
|
||||||
|
if !isAgent && n.OutboundTag == "" && old.OutboundTag == "" && (!n.ClearApiToken || n.Enable) {
|
||||||
if err := a.ensureReachable(c, n, id); err != nil {
|
if err := a.ensureReachable(c, n, id); err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
@@ -25,16 +23,14 @@ var filenameRegex = regexp.MustCompile(`^[a-zA-Z0-9_\-.]+$`)
|
|||||||
type ServerController struct {
|
type ServerController struct {
|
||||||
BaseController
|
BaseController
|
||||||
|
|
||||||
serverService service.ServerService
|
serverService service.ServerService
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
panelService panel.PanelService
|
panelService panel.PanelService
|
||||||
xrayMetricsService service.XrayMetricsService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServerController creates a new ServerController, initializes routes, and starts background tasks.
|
// NewServerController creates a new ServerController, initializes routes, and starts background tasks.
|
||||||
func NewServerController(g *gin.RouterGroup) *ServerController {
|
func NewServerController(g *gin.RouterGroup) *ServerController {
|
||||||
a := &ServerController{}
|
a := &ServerController{}
|
||||||
service.RestoreSystemMetrics()
|
|
||||||
a.initRouter(g)
|
a.initRouter(g)
|
||||||
a.startTask()
|
a.startTask()
|
||||||
return a
|
return a
|
||||||
@@ -43,12 +39,6 @@ func NewServerController(g *gin.RouterGroup) *ServerController {
|
|||||||
// initRouter sets up the routes for server status, Xray management, and utility endpoints.
|
// initRouter sets up the routes for server status, Xray management, and utility endpoints.
|
||||||
func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
||||||
g.GET("/status", a.status)
|
g.GET("/status", a.status)
|
||||||
g.GET("/cpuHistory/:bucket", a.getCpuHistoryBucket)
|
|
||||||
g.GET("/history/:metric/:bucket", a.getMetricHistoryBucket)
|
|
||||||
g.GET("/xrayMetricsState", a.getXrayMetricsState)
|
|
||||||
g.GET("/xrayMetricsHistory/:metric/:bucket", a.getXrayMetricsHistoryBucket)
|
|
||||||
g.GET("/xrayObservatory", a.getXrayObservatory)
|
|
||||||
g.GET("/xrayObservatoryHistory/:tag/:bucket", a.getXrayObservatoryHistoryBucket)
|
|
||||||
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo)
|
||||||
g.GET("/getUpdateStatus", a.getUpdateStatus)
|
g.GET("/getUpdateStatus", a.getUpdateStatus)
|
||||||
g.GET("/getConfigJson", a.getConfigJson)
|
g.GET("/getConfigJson", a.getConfigJson)
|
||||||
@@ -68,9 +58,6 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.POST("/restartXrayService", a.restartXrayService)
|
g.POST("/restartXrayService", a.restartXrayService)
|
||||||
g.POST("/updatePanel", a.updatePanel)
|
g.POST("/updatePanel", a.updatePanel)
|
||||||
g.POST("/setUpdateChannel", a.setUpdateChannel)
|
g.POST("/setUpdateChannel", a.setUpdateChannel)
|
||||||
g.POST("/logs/:count", a.getLogs)
|
|
||||||
g.POST("/xraylogs/:count", a.getXrayLogs)
|
|
||||||
g.POST("/amneziawglogs/:count", a.getAmneziaWGLogs)
|
|
||||||
g.POST("/importDB", a.importDB)
|
g.POST("/importDB", a.importDB)
|
||||||
g.POST("/getNewEchCert", a.getNewEchCert)
|
g.POST("/getNewEchCert", a.getNewEchCert)
|
||||||
g.POST("/getCertHash", a.getCertHash)
|
g.POST("/getCertHash", a.getCertHash)
|
||||||
@@ -80,10 +67,11 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.POST("/clientIps", a.setClientIps)
|
g.POST("/clientIps", a.setClientIps)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startTask registers the @2s ticker that refreshes server status, samples
|
// startTask registers the @2s ticker that refreshes server status and pushes
|
||||||
// xray metrics, and pushes the new snapshot to all websocket subscribers.
|
// the new snapshot to all websocket subscribers. State lives in
|
||||||
// State + sampling live in ServerService; the controller only orchestrates
|
// ServerService; the controller only orchestrates the broadcast. (The xray
|
||||||
// the cross-service side effects (xrayMetrics sample + websocket broadcast).
|
// metrics sampling and the per-minute system-history persistence that used
|
||||||
|
// to ride along here were removed with those features.)
|
||||||
func (a *ServerController) startTask() {
|
func (a *ServerController) startTask() {
|
||||||
c := global.GetWebServer().GetCron()
|
c := global.GetWebServer().GetCron()
|
||||||
_, _ = c.AddFunc("@every 2s", func() {
|
_, _ = c.AddFunc("@every 2s", func() {
|
||||||
@@ -91,14 +79,8 @@ func (a *ServerController) startTask() {
|
|||||||
if status == nil {
|
if status == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.xrayMetricsService.Sample(time.Now())
|
|
||||||
websocket.BroadcastStatus(status)
|
websocket.BroadcastStatus(status)
|
||||||
})
|
})
|
||||||
_, _ = c.AddFunc("@every 1m", func() {
|
|
||||||
if err := service.PersistSystemMetrics(); err != nil {
|
|
||||||
logger.Warning("persist system metrics failed:", err)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// status returns the current server status information.
|
// status returns the current server status information.
|
||||||
@@ -108,76 +90,6 @@ func (a *ServerController) getFail2banStatus(c *gin.Context) {
|
|||||||
jsonObj(c, a.serverService.GetFail2banStatus(), nil)
|
jsonObj(c, a.serverService.GetFail2banStatus(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseHistoryBucket(c *gin.Context) (int, bool) {
|
|
||||||
bucket, err := strconv.Atoi(c.Param("bucket"))
|
|
||||||
if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {
|
|
||||||
jsonMsg(c, "invalid bucket", fmt.Errorf("unsupported bucket"))
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return bucket, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// getCpuHistoryBucket retrieves aggregated CPU usage history based on the specified time bucket.
|
|
||||||
// Kept for back-compat; new callers should use /history/cpu/:bucket which
|
|
||||||
// returns {"t","v"} (uniform across all metrics) instead of {"t","cpu"}.
|
|
||||||
func (a *ServerController) getCpuHistoryBucket(c *gin.Context) {
|
|
||||||
bucket, ok := parseHistoryBucket(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonObj(c, a.serverService.AggregateCpuHistory(bucket, 60), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getMetricHistoryBucket returns up to 60 buckets of history for a single
|
|
||||||
// system metric (cpu, mem, netUp, netDown, online, load1/5/15). The
|
|
||||||
// SystemHistoryModal calls one endpoint per active tab.
|
|
||||||
func (a *ServerController) getMetricHistoryBucket(c *gin.Context) {
|
|
||||||
metric := c.Param("metric")
|
|
||||||
if !slices.Contains(service.SystemMetricKeys, metric) {
|
|
||||||
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bucket, ok := parseHistoryBucket(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonObj(c, a.serverService.AggregateSystemMetric(metric, bucket, 60), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *ServerController) getXrayMetricsState(c *gin.Context) {
|
|
||||||
jsonObj(c, a.xrayMetricsService.State(), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *ServerController) getXrayMetricsHistoryBucket(c *gin.Context) {
|
|
||||||
metric := c.Param("metric")
|
|
||||||
if !slices.Contains(service.XrayMetricKeys, metric) {
|
|
||||||
jsonMsg(c, "invalid metric", fmt.Errorf("unknown metric"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bucket, ok := parseHistoryBucket(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonObj(c, a.xrayMetricsService.AggregateMetric(metric, bucket, 60), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *ServerController) getXrayObservatory(c *gin.Context) {
|
|
||||||
jsonObj(c, a.xrayMetricsService.ObservatorySnapshot(), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *ServerController) getXrayObservatoryHistoryBucket(c *gin.Context) {
|
|
||||||
tag := c.Param("tag")
|
|
||||||
if !a.xrayMetricsService.HasObservatoryTag(tag) {
|
|
||||||
jsonMsg(c, "invalid tag", fmt.Errorf("unknown observatory tag"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
bucket, ok := parseHistoryBucket(c)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonObj(c, a.xrayMetricsService.AggregateObservatory(tag, bucket, 60), nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getPanelUpdateInfo retrieves the current and latest panel version.
|
// getPanelUpdateInfo retrieves the current and latest panel version.
|
||||||
func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
|
func (a *ServerController) getPanelUpdateInfo(c *gin.Context) {
|
||||||
info, err := a.panelService.GetUpdateInfo()
|
info, err := a.panelService.GetUpdateInfo()
|
||||||
@@ -266,34 +178,6 @@ func (a *ServerController) restartXrayService(c *gin.Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLogs retrieves the application logs based on count, level, and syslog filters.
|
|
||||||
func (a *ServerController) getLogs(c *gin.Context) {
|
|
||||||
logs := a.serverService.GetLogs(c.Param("count"), c.PostForm("level"), c.PostForm("syslog"))
|
|
||||||
jsonObj(c, logs, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getXrayLogs retrieves Xray logs with filtering options for direct, blocked, and proxy traffic.
|
|
||||||
func (a *ServerController) getXrayLogs(c *gin.Context) {
|
|
||||||
freedoms, blackholes := a.serverService.GetDefaultLogOutboundTags()
|
|
||||||
logs := a.serverService.GetXrayLogs(
|
|
||||||
c.Param("count"),
|
|
||||||
c.PostForm("filter"),
|
|
||||||
c.PostForm("showDirect"),
|
|
||||||
c.PostForm("showBlocked"),
|
|
||||||
c.PostForm("showProxy"),
|
|
||||||
freedoms,
|
|
||||||
blackholes,
|
|
||||||
)
|
|
||||||
jsonObj(c, logs, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getAmneziaWGLogs retrieves the live AmneziaWG peer activity and the panel's
|
|
||||||
// own AmneziaWG event lines, optionally narrowed by a free-text filter.
|
|
||||||
func (a *ServerController) getAmneziaWGLogs(c *gin.Context) {
|
|
||||||
logs := a.serverService.GetAmneziaWGLogs(c.Param("count"), c.PostForm("filter"))
|
|
||||||
jsonObj(c, logs, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getConfigJson retrieves the Xray configuration as JSON.
|
// getConfigJson retrieves the Xray configuration as JSON.
|
||||||
func (a *ServerController) getConfigJson(c *gin.Context) {
|
func (a *ServerController) getConfigJson(c *gin.Context) {
|
||||||
configJson, err := a.serverService.GetConfigJson()
|
configJson, err := a.serverService.GetConfigJson()
|
||||||
|
|||||||
@@ -40,19 +40,23 @@ type AllSetting struct {
|
|||||||
|
|
||||||
TimeLocation string `json:"timeLocation" form:"timeLocation"`
|
TimeLocation string `json:"timeLocation" form:"timeLocation"`
|
||||||
|
|
||||||
SubEnable bool `json:"subEnable" form:"subEnable"`
|
SubEnable bool `json:"subEnable" form:"subEnable"`
|
||||||
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"`
|
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"`
|
||||||
SubJsonAutoDetect bool `json:"subJsonAutoDetect" form:"subJsonAutoDetect"`
|
SubJsonAutoDetect bool `json:"subJsonAutoDetect" form:"subJsonAutoDetect"`
|
||||||
SubJsonAlwaysArray bool `json:"subJsonAlwaysArray" form:"subJsonAlwaysArray"`
|
SubJsonAlwaysArray bool `json:"subJsonAlwaysArray" form:"subJsonAlwaysArray"`
|
||||||
SubJsonUserAgentRegex string `json:"subJsonUserAgentRegex" form:"subJsonUserAgentRegex"`
|
SubJsonUserAgentRegex string `json:"subJsonUserAgentRegex" form:"subJsonUserAgentRegex"`
|
||||||
SubClashAutoDetect bool `json:"subClashAutoDetect" form:"subClashAutoDetect"`
|
SubClashAutoDetect bool `json:"subClashAutoDetect" form:"subClashAutoDetect"`
|
||||||
SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
|
SubClashUserAgentRegex string `json:"subClashUserAgentRegex" form:"subClashUserAgentRegex"`
|
||||||
SubTitle string `json:"subTitle" form:"subTitle"`
|
SubTitle string `json:"subTitle" form:"subTitle"`
|
||||||
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
|
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
|
||||||
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
|
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
|
||||||
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
|
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
|
||||||
SubListen string `json:"subListen" form:"subListen"`
|
SubListen string `json:"subListen" form:"subListen"`
|
||||||
SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`
|
// SubPort 0 means "no explicit port": subscription links are generated
|
||||||
|
// without a port (standard scheme port behind a reverse proxy) while the
|
||||||
|
// sub server still binds the default port. See SettingService.BuildSubURIBase
|
||||||
|
// and the sub server bootstrap for the two sides of that split.
|
||||||
|
SubPort int `json:"subPort" form:"subPort" validate:"gte=0,lte=65535"`
|
||||||
SubPath string `json:"subPath" form:"subPath"`
|
SubPath string `json:"subPath" form:"subPath"`
|
||||||
SubDomain string `json:"subDomain" form:"subDomain"`
|
SubDomain string `json:"subDomain" form:"subDomain"`
|
||||||
SubCertFile string `json:"subCertFile" form:"subCertFile"`
|
SubCertFile string `json:"subCertFile" form:"subCertFile"`
|
||||||
@@ -147,11 +151,11 @@ func (s *AllSetting) CheckValid() error {
|
|||||||
return common.NewError("web port is not a valid port:", s.WebPort)
|
return common.NewError("web port is not a valid port:", s.WebPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.SubPort <= 0 || s.SubPort > math.MaxUint16 {
|
if s.SubPort < 0 || s.SubPort > math.MaxUint16 {
|
||||||
return common.NewError("Sub port is not a valid port:", s.SubPort)
|
return common.NewError("Sub port is not a valid port:", s.SubPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (s.SubPort == s.WebPort) && listenAddressesConflict(s.WebListen, s.SubListen) {
|
if s.SubPort > 0 && (s.SubPort == s.WebPort) && listenAddressesConflict(s.WebListen, s.SubListen) {
|
||||||
return common.NewError("Sub and Web could not use same ip:port, ", s.SubListen, ":", s.SubPort, " & ", s.WebListen, ":", s.WebPort)
|
return common.NewError("Sub and Web could not use same ip:port, ", s.SubListen, ":", s.SubPort, " & ", s.WebListen, ":", s.WebPort)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package job
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
@@ -10,6 +11,22 @@ import (
|
|||||||
|
|
||||||
// The protocol gate must let hysteria through: XrayAPI supports it, and the
|
// The protocol gate must let hysteria through: XrayAPI supports it, and the
|
||||||
// skip left over-limit Hysteria2 sessions alive until the fail2ban ban caught up.
|
// skip left over-limit Hysteria2 sessions alive until the fail2ban ban caught up.
|
||||||
|
// captureLogLines installs a logger hook collecting "LEVEL message" lines
|
||||||
|
// for the rest of the test. It replaces reads of the in-memory log buffer,
|
||||||
|
// which was removed together with the web log viewer.
|
||||||
|
func captureLogLines(t *testing.T) *[]string {
|
||||||
|
t.Helper()
|
||||||
|
var mu sync.Mutex
|
||||||
|
lines := &[]string{}
|
||||||
|
prev := logger.SetLogHook(func(level, msg string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
*lines = append(*lines, level+" "+msg)
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { logger.SetLogHook(prev) })
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
func TestDisconnectClientTemporarilyAllowsHysteria(t *testing.T) {
|
func TestDisconnectClientTemporarilyAllowsHysteria(t *testing.T) {
|
||||||
setupIntegrationDB(t)
|
setupIntegrationDB(t)
|
||||||
|
|
||||||
@@ -22,10 +39,11 @@ func TestDisconnectClientTemporarilyAllowsHysteria(t *testing.T) {
|
|||||||
}
|
}
|
||||||
clients := []model.Client{{Email: email, Auth: "secret"}}
|
clients := []model.Client{{Email: email, Auth: "secret"}}
|
||||||
|
|
||||||
|
logLines := captureLogLines(t)
|
||||||
(&CheckClientIpJob{}).disconnectClientTemporarily(inbound, email, clients)
|
(&CheckClientIpJob{}).disconnectClientTemporarily(inbound, email, clients)
|
||||||
|
|
||||||
var unsupported, attempted bool
|
var unsupported, attempted bool
|
||||||
for _, line := range logger.GetLogs(500, "warning") {
|
for _, line := range *logLines {
|
||||||
if strings.Contains(line, "Temporary disconnect is not supported for protocol hysteria") {
|
if strings.Contains(line, "Temporary disconnect is not supported for protocol hysteria") {
|
||||||
unsupported = true
|
unsupported = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgentHeartbeatStale(t *testing.T) {
|
||||||
|
now := time.Unix(1_700_000_000, 0)
|
||||||
|
ms := func(d time.Duration) int64 { return now.Add(-d).UnixMilli() }
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
lastSeenMs int64
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"never checked in", 0, false},
|
||||||
|
{"negative stamp", -1, false},
|
||||||
|
{"fresh heartbeat", ms(60 * time.Second), false},
|
||||||
|
{"two missed heartbeats", ms(179 * time.Second), false},
|
||||||
|
{"past threshold", ms(181 * time.Second), true},
|
||||||
|
{"long gone", ms(24 * time.Hour), true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
if got := agentHeartbeatStale(tc.lastSeenMs, now); got != tc.want {
|
||||||
|
t.Errorf("%s: agentHeartbeatStale(%d) = %v, want %v", tc.name, tc.lastSeenMs, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedAgentNode(t *testing.T, name, status string, lastSeenMs int64) *model.Node {
|
||||||
|
t.Helper()
|
||||||
|
db := database.GetDB()
|
||||||
|
n := &model.Node{
|
||||||
|
Name: name,
|
||||||
|
Address: "agent",
|
||||||
|
Port: 2053,
|
||||||
|
Enable: true,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
if err := db.Create(n).Error; err != nil {
|
||||||
|
t.Fatalf("create node: %v", err)
|
||||||
|
}
|
||||||
|
a := &model.AgentNode{
|
||||||
|
TokenHash: "hash-" + name,
|
||||||
|
Name: name,
|
||||||
|
NodeID: &n.Id,
|
||||||
|
LastSeen: lastSeenMs,
|
||||||
|
}
|
||||||
|
if err := db.Create(a).Error; err != nil {
|
||||||
|
t.Fatalf("create agent: %v", err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func nodeStatus(t *testing.T, id int) string {
|
||||||
|
t.Helper()
|
||||||
|
var n model.Node
|
||||||
|
if err := database.GetDB().Where("id = ?", id).First(&n).Error; err != nil {
|
||||||
|
t.Fatalf("reload node: %v", err)
|
||||||
|
}
|
||||||
|
return n.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeOneExpiresStaleAgentNode(t *testing.T) {
|
||||||
|
setupIntegrationDB(t)
|
||||||
|
stale := time.Now().Add(-10 * time.Minute).UnixMilli()
|
||||||
|
n := seedAgentNode(t, "stale-agent", "online", stale)
|
||||||
|
|
||||||
|
(&NodeHeartbeatJob{}).probeOne(n)
|
||||||
|
|
||||||
|
if got := nodeStatus(t, n.Id); got != "offline" {
|
||||||
|
t.Fatalf("stale agent node status = %q, want offline", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeOneKeepsFreshAgentNodeOnline(t *testing.T) {
|
||||||
|
setupIntegrationDB(t)
|
||||||
|
fresh := time.Now().Add(-30 * time.Second).UnixMilli()
|
||||||
|
n := seedAgentNode(t, "fresh-agent", "online", fresh)
|
||||||
|
|
||||||
|
(&NodeHeartbeatJob{}).probeOne(n)
|
||||||
|
|
||||||
|
if got := nodeStatus(t, n.Id); got != "online" {
|
||||||
|
t.Fatalf("fresh agent node status = %q, want online", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeOneLeavesUnactivatedAgentNodeAlone(t *testing.T) {
|
||||||
|
setupIntegrationDB(t)
|
||||||
|
// Token issued, agent never checked in: last_seen = 0. The node must not
|
||||||
|
// be forced offline — it was never online/activated in the first place.
|
||||||
|
n := seedAgentNode(t, "dormant-agent", "unknown", 0)
|
||||||
|
|
||||||
|
(&NodeHeartbeatJob{}).probeOne(n)
|
||||||
|
|
||||||
|
if got := nodeStatus(t, n.Id); got != "unknown" {
|
||||||
|
t.Fatalf("unactivated agent node status = %q, want unknown (untouched)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProbeOnePanelNodeStillProbed(t *testing.T) {
|
||||||
|
setupIntegrationDB(t)
|
||||||
|
db := database.GetDB()
|
||||||
|
// No agent bound: the legacy probe path must still run and mark an
|
||||||
|
// unreachable panel node offline.
|
||||||
|
n := &model.Node{
|
||||||
|
Name: "panel-node",
|
||||||
|
Scheme: "http",
|
||||||
|
Address: "127.0.0.1",
|
||||||
|
Port: 1, // nothing listens here; probe fails fast
|
||||||
|
Enable: true,
|
||||||
|
Status: "online",
|
||||||
|
}
|
||||||
|
if err := db.Create(n).Error; err != nil {
|
||||||
|
t.Fatalf("create node: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
(&NodeHeartbeatJob{}).probeOne(n)
|
||||||
|
|
||||||
|
if got := nodeStatus(t, n.Id); got != "offline" {
|
||||||
|
t.Fatalf("unreachable panel node status = %q, want offline", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,13 @@ const (
|
|||||||
nodeHeartbeatRequestTimeout = 4 * time.Second
|
nodeHeartbeatRequestTimeout = 4 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// agentHeartbeatStaleAfter is how long an agent-managed node may go without
|
||||||
|
// an agent heartbeat before the heartbeat job marks it offline. The node-side
|
||||||
|
// agent heartbeats every 60s (defaultHeartbeatInterval in internal/agent);
|
||||||
|
// the threshold is max(3 intervals, 120s) = 180s, so two consecutive lost
|
||||||
|
// heartbeats still don't flap the node offline.
|
||||||
|
const agentHeartbeatStaleAfter = 180 * time.Second
|
||||||
|
|
||||||
type NodeHeartbeatJob struct {
|
type NodeHeartbeatJob struct {
|
||||||
nodeService service.NodeService
|
nodeService service.NodeService
|
||||||
running sync.Mutex
|
running sync.Mutex
|
||||||
@@ -75,10 +82,13 @@ func (j *NodeHeartbeatJob) Run() {
|
|||||||
func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
|
func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
|
||||||
// Agent-managed nodes are heartbeated via the agent token, not the
|
// Agent-managed nodes are heartbeated via the agent token, not the
|
||||||
// legacy panel probe. Skip the probe so we don't clobber the
|
// legacy panel probe. Skip the probe so we don't clobber the
|
||||||
// agent-driven online status.
|
// agent-driven online status — but do expire that status when the
|
||||||
|
// agent stops heartbeating, or an uninstalled agent would leave its
|
||||||
|
// node showing online forever.
|
||||||
var agentCount int64
|
var agentCount int64
|
||||||
_ = database.GetDB().Model(&model.AgentNode{}).Where("node_id = ?", n.Id).Count(&agentCount).Error
|
_ = database.GetDB().Model(&model.AgentNode{}).Where("node_id = ?", n.Id).Count(&agentCount).Error
|
||||||
if agentCount > 0 {
|
if agentCount > 0 {
|
||||||
|
j.expireStaleAgentNode(n)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
|
||||||
@@ -106,6 +116,69 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// agentLastSeenMs returns the newest last_seen (Unix milliseconds, the unit
|
||||||
|
// the agent controller stamps on register/heartbeat) across the agents bound
|
||||||
|
// to the node, or 0 when no bound agent has ever checked in.
|
||||||
|
func agentLastSeenMs(nodeID int) int64 {
|
||||||
|
var lastSeen *int64 // MAX() over zero rows is NULL
|
||||||
|
_ = database.GetDB().Model(&model.AgentNode{}).
|
||||||
|
Where("node_id = ?", nodeID).
|
||||||
|
Select("MAX(last_seen)").
|
||||||
|
Scan(&lastSeen).Error
|
||||||
|
if lastSeen == nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return *lastSeen
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentHeartbeatStale decides expiry from the agent's last_seen stamp. A
|
||||||
|
// zero stamp means the token was issued but the agent never checked in: the
|
||||||
|
// node was never activated, so expiry leaves it alone (its status stays
|
||||||
|
// whatever creation left it — "unknown") instead of forcing a just-created
|
||||||
|
// node offline before its agent had a chance to come up.
|
||||||
|
func agentHeartbeatStale(lastSeenMs int64, now time.Time) bool {
|
||||||
|
if lastSeenMs <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return now.Sub(time.UnixMilli(lastSeenMs)) > agentHeartbeatStaleAfter
|
||||||
|
}
|
||||||
|
|
||||||
|
// expireStaleAgentNode flips an agent-managed node offline once its agent's
|
||||||
|
// heartbeats stop. Only the status changes: the patch carries the node's
|
||||||
|
// stored heartbeat fields through unchanged (including the last_heartbeat
|
||||||
|
// stamp — the truth is "last seen then", not "now"), and the transition goes
|
||||||
|
// through the same UpdateHeartbeat + broadcast path as a failed panel probe,
|
||||||
|
// so node.down subscribers see agent expiries too.
|
||||||
|
func (j *NodeHeartbeatJob) expireStaleAgentNode(n *model.Node) {
|
||||||
|
if n.Status == "offline" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !agentHeartbeatStale(agentLastSeenMs(n.Id), time.Now()) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
patch := service.HeartbeatPatch{
|
||||||
|
Status: "offline",
|
||||||
|
LastHeartbeat: n.LastHeartbeat,
|
||||||
|
LatencyMs: n.LatencyMs,
|
||||||
|
XrayVersion: n.XrayVersion,
|
||||||
|
PanelVersion: n.PanelVersion,
|
||||||
|
Guid: n.Guid,
|
||||||
|
CpuPct: n.CpuPct,
|
||||||
|
MemPct: n.MemPct,
|
||||||
|
UptimeSecs: n.UptimeSecs,
|
||||||
|
NetUp: n.NetUp,
|
||||||
|
NetDown: n.NetDown,
|
||||||
|
LastError: n.LastError,
|
||||||
|
XrayState: n.XrayState,
|
||||||
|
XrayError: n.XrayError,
|
||||||
|
}
|
||||||
|
if err := j.nodeService.UpdateHeartbeat(n.Id, patch); err != nil {
|
||||||
|
logger.Warning("node heartbeat: expire agent node", n.Id, "failed:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
publishNodeTransition(n, n.Status, patch)
|
||||||
|
}
|
||||||
|
|
||||||
// publishNodeTransition emits node.down / node.up only on a genuine state change.
|
// publishNodeTransition emits node.down / node.up only on a genuine state change.
|
||||||
// An "unknown"/empty previous status (fresh start) is treated as not-online, so a
|
// An "unknown"/empty previous status (fresh start) is treated as not-online, so a
|
||||||
// node coming up for the first time fires node.up but never a spurious node.down.
|
// node coming up for the first time fires node.up but never a spurious node.down.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||||
@@ -27,11 +28,28 @@ type testAddr string
|
|||||||
func (a testAddr) Network() string { return string(a) }
|
func (a testAddr) Network() string { return string(a) }
|
||||||
func (a testAddr) String() string { return string(a) }
|
func (a testAddr) String() string { return string(a) }
|
||||||
|
|
||||||
|
// captureLogLines installs a logger hook collecting "LEVEL message" lines
|
||||||
|
// for the rest of the test. It replaces reads of the in-memory log buffer,
|
||||||
|
// which was removed together with the web log viewer.
|
||||||
|
func captureLogLines(t *testing.T) *[]string {
|
||||||
|
t.Helper()
|
||||||
|
var mu sync.Mutex
|
||||||
|
lines := &[]string{}
|
||||||
|
prev := logger.SetLogHook(func(level, msg string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
*lines = append(*lines, level+" "+msg)
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { logger.SetLogHook(prev) })
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
func TestServeHTTPLogsUnexpectedListenerFailure(t *testing.T) {
|
func TestServeHTTPLogsUnexpectedListenerFailure(t *testing.T) {
|
||||||
errInjected := errors.New("injected listener failure")
|
errInjected := errors.New("injected listener failure")
|
||||||
|
logLines := captureLogLines(t)
|
||||||
ServeHTTP(&http.Server{}, failingListener{err: errInjected}, "Test server")
|
ServeHTTP(&http.Server{}, failingListener{err: errInjected}, "Test server")
|
||||||
|
|
||||||
for _, line := range logger.GetLogs(100, "error") {
|
for _, line := range *logLines {
|
||||||
if strings.Contains(line, errInjected.Error()) {
|
if strings.Contains(line, errInjected.Error()) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -41,9 +59,10 @@ func TestServeHTTPLogsUnexpectedListenerFailure(t *testing.T) {
|
|||||||
|
|
||||||
func TestServeHTTPSuppressesNormalServerClose(t *testing.T) {
|
func TestServeHTTPSuppressesNormalServerClose(t *testing.T) {
|
||||||
const marker = "normal-close-must-stay-silent"
|
const marker = "normal-close-must-stay-silent"
|
||||||
|
logLines := captureLogLines(t)
|
||||||
ServeHTTP(&http.Server{}, failingListener{err: http.ErrServerClosed}, marker)
|
ServeHTTP(&http.Server{}, failingListener{err: http.ErrServerClosed}, marker)
|
||||||
|
|
||||||
for _, line := range logger.GetLogs(100, "error") {
|
for _, line := range *logLines {
|
||||||
if strings.Contains(line, marker) {
|
if strings.Contains(line, marker) {
|
||||||
t.Fatalf("normal http.ErrServerClosed was recorded as an error: %s", line)
|
t.Fatalf("normal http.ErrServerClosed was recorded as an error: %s", line)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import "testing"
|
|
||||||
|
|
||||||
func TestParseAccessLogFields(t *testing.T) {
|
|
||||||
malformed := []string{
|
|
||||||
"",
|
|
||||||
"singletoken",
|
|
||||||
"2024/01/02",
|
|
||||||
"2024/01/02 15:04:05.000000 from",
|
|
||||||
"2024/01/02 15:04:05.000000 accepted",
|
|
||||||
"2024/01/02 15:04:05.000000 email:",
|
|
||||||
}
|
|
||||||
for _, line := range malformed {
|
|
||||||
func() {
|
|
||||||
defer func() {
|
|
||||||
if r := recover(); r != nil {
|
|
||||||
t.Errorf("parseAccessLogFields panicked on %q: %v", line, r)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
_ = parseAccessLogFields(line)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
line := "2024/01/02 15:04:05.123456 from 1.2.3.4:555 accepted tcp:example.com:443 [inbound-tag >> outbound-tag] email: alice@example.com"
|
|
||||||
entry := parseAccessLogFields(line)
|
|
||||||
if entry.FromAddress != "1.2.3.4:555" {
|
|
||||||
t.Errorf("FromAddress = %q, want %q", entry.FromAddress, "1.2.3.4:555")
|
|
||||||
}
|
|
||||||
if entry.ToAddress != "tcp:example.com:443" {
|
|
||||||
t.Errorf("ToAddress = %q, want %q", entry.ToAddress, "tcp:example.com:443")
|
|
||||||
}
|
|
||||||
if entry.Inbound != "inbound-tag" {
|
|
||||||
t.Errorf("Inbound = %q, want %q", entry.Inbound, "inbound-tag")
|
|
||||||
}
|
|
||||||
if entry.Outbound != "outbound-tag" {
|
|
||||||
t.Errorf("Outbound = %q, want %q", entry.Outbound, "outbound-tag")
|
|
||||||
}
|
|
||||||
if entry.Email != "alice@example.com" {
|
|
||||||
t.Errorf("Email = %q, want %q", entry.Email, "alice@example.com")
|
|
||||||
}
|
|
||||||
if entry.DateTime.IsZero() {
|
|
||||||
t.Error("DateTime was not parsed from a well-formed line")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,13 +7,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/op/go-logging"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCheckForwardedPortsConflict_EmptySpecNoConflict(t *testing.T) {
|
func TestCheckForwardedPortsConflict_EmptySpecNoConflict(t *testing.T) {
|
||||||
@@ -275,37 +272,6 @@ func TestNormalizeAmneziaWGSettings_CanonicalizesClientAllowedIPs(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGetAmneziaWGLogs_ClampsCountAndFiltersEvents(t *testing.T) {
|
|
||||||
logger.InitLogger(logging.DEBUG)
|
|
||||||
logger.Info("amneziawg: started interface awg1 for inbound 1")
|
|
||||||
logger.Info("xray: unrelated line that must never show up here")
|
|
||||||
logger.Warning("amneziawgnet: reconcile failed for inbound 2: handshake timeout")
|
|
||||||
|
|
||||||
svc := &ServerService{}
|
|
||||||
logs := svc.GetAmneziaWGLogs("not-a-number", "")
|
|
||||||
if logs == nil {
|
|
||||||
t.Fatal("GetAmneziaWGLogs must never return nil")
|
|
||||||
}
|
|
||||||
for _, line := range logs.Events {
|
|
||||||
if !strings.Contains(strings.ToLower(line), "amneziawg") {
|
|
||||||
t.Fatalf("non-AmneziaWG line leaked into the event list: %q", line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(logs.Events) < 2 {
|
|
||||||
t.Fatalf("both AmneziaWG lines should be present, got %v", logs.Events)
|
|
||||||
}
|
|
||||||
|
|
||||||
// count caps the event list, so an operator asking for 1 gets 1.
|
|
||||||
if one := svc.GetAmneziaWGLogs("1", ""); len(one.Events) != 1 {
|
|
||||||
t.Fatalf("count=1 must cap the event list, got %d", len(one.Events))
|
|
||||||
}
|
|
||||||
// filter narrows further, case-insensitively.
|
|
||||||
filtered := svc.GetAmneziaWGLogs("100", "RECONCILE")
|
|
||||||
if len(filtered.Events) != 1 || !strings.Contains(filtered.Events[0], "reconcile") {
|
|
||||||
t.Fatalf("filter must narrow to the matching line, got %v", filtered.Events)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckForwardedPortsConflict_RejectsSpecOverCap(t *testing.T) {
|
func TestCheckForwardedPortsConflict_RejectsSpecOverCap(t *testing.T) {
|
||||||
setupConflictDB(t)
|
setupConflictDB(t)
|
||||||
svc := &InboundService{}
|
svc := &InboundService{}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
@@ -15,8 +16,25 @@ import (
|
|||||||
|
|
||||||
// Removing a central inbound also drops its clients' traffic history, so the
|
// Removing a central inbound also drops its clients' traffic history, so the
|
||||||
// sweep must name what it removed — a silent drop is undiagnosable in the field.
|
// sweep must name what it removed — a silent drop is undiagnosable in the field.
|
||||||
|
// captureLogLines installs a logger hook collecting "LEVEL message" lines
|
||||||
|
// for the rest of the test. It replaces reads of the in-memory log buffer,
|
||||||
|
// which was removed together with the web log viewer.
|
||||||
|
func captureLogLines(t *testing.T) *[]string {
|
||||||
|
t.Helper()
|
||||||
|
var mu sync.Mutex
|
||||||
|
lines := &[]string{}
|
||||||
|
prev := logger.SetLogHook(func(level, msg string) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
*lines = append(*lines, level+" "+msg)
|
||||||
|
})
|
||||||
|
t.Cleanup(func() { logger.SetLogHook(prev) })
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
func TestNodeSnapshotSweepLogsRemovedInbound(t *testing.T) {
|
func TestNodeSnapshotSweepLogsRemovedInbound(t *testing.T) {
|
||||||
logger.InitLogger(logging.WARNING)
|
logger.InitLogger(logging.WARNING)
|
||||||
|
logLines := captureLogLines(t)
|
||||||
setupBulkDB(t)
|
setupBulkDB(t)
|
||||||
nodeID, _ := setupNodeRuntime(t)
|
nodeID, _ := setupNodeRuntime(t)
|
||||||
|
|
||||||
@@ -41,7 +59,7 @@ func TestNodeSnapshotSweepLogsRemovedInbound(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
want := strconv.Itoa(gone.Id)
|
want := strconv.Itoa(gone.Id)
|
||||||
for _, line := range logger.GetLogs(200, "warning") {
|
for _, line := range *logLines {
|
||||||
if strings.Contains(line, gone.Tag) && strings.Contains(line, want) {
|
if strings.Contains(line, gone.Tag) && strings.Contains(line, want) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/gob"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"slices"
|
"slices"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// MetricSample is one point of any time-series we keep in memory.
|
// MetricSample is one point of any time-series we keep in memory.
|
||||||
@@ -118,8 +112,7 @@ func (s *series) pickTier(spanSeconds int64) *tierBuf {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// metricHistory is a thread-safe, in-memory store of tiered series keyed by
|
// metricHistory is a thread-safe, in-memory store of tiered series keyed by
|
||||||
// arbitrary strings. Three singletons live below: system-wide host metrics,
|
// arbitrary strings. One singleton lives below: per-node metrics.
|
||||||
// per-node metrics, and xray expvar metrics.
|
|
||||||
type metricHistory struct {
|
type metricHistory struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
series map[string]*series
|
series map[string]*series
|
||||||
@@ -215,156 +208,12 @@ func (h *metricHistory) aggregate(metric string, bucketSeconds int, maxPoints in
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// persistedTier and persistedSeries are the on-disk shape of a series. Tiers are
|
// nodeMetrics is the remaining singleton: per-node CPU/Mem/net series fed by
|
||||||
// matched back by resolution on restore, so changing the ladder degrades
|
// NodeHeartbeatJob (via UpdateHeartbeat) and read by the node history panel.
|
||||||
// gracefully (unmatched layers are dropped) instead of corrupting state.
|
// The whole-host system series and the xray expvar series that used to live
|
||||||
type persistedTier struct {
|
// here were removed with the system-history and xray-metrics features,
|
||||||
Resolution int
|
// together with the gob persistence of the host series.
|
||||||
Samples []MetricSample
|
var nodeMetrics = newMetricHistory()
|
||||||
}
|
|
||||||
|
|
||||||
type persistedSeries struct {
|
|
||||||
Tiers []persistedTier
|
|
||||||
}
|
|
||||||
|
|
||||||
// snapshot returns a deep copy of every series' closed buckets, safe to
|
|
||||||
// serialize without holding the lock during disk I/O.
|
|
||||||
func (h *metricHistory) snapshot() map[string]persistedSeries {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
out := make(map[string]persistedSeries, len(h.series))
|
|
||||||
for k, s := range h.series {
|
|
||||||
ps := persistedSeries{Tiers: make([]persistedTier, len(s.tiers))}
|
|
||||||
for i, tb := range s.tiers {
|
|
||||||
cp := make([]MetricSample, len(tb.samples))
|
|
||||||
copy(cp, tb.samples)
|
|
||||||
ps.Tiers[i] = persistedTier{Resolution: tb.resolution, Samples: cp}
|
|
||||||
}
|
|
||||||
out[k] = ps
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// restore replaces the in-memory series with a previously persisted set,
|
|
||||||
// re-applying each tier's capacity cap so a tampered or oversized file can't grow
|
|
||||||
// the working set unbounded.
|
|
||||||
func (h *metricHistory) restore(data map[string]persistedSeries) {
|
|
||||||
h.mu.Lock()
|
|
||||||
defer h.mu.Unlock()
|
|
||||||
for k, ps := range data {
|
|
||||||
s := newSeries()
|
|
||||||
for _, pt := range ps.Tiers {
|
|
||||||
for _, tb := range s.tiers {
|
|
||||||
if tb.resolution != pt.Resolution {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
samples := pt.Samples
|
|
||||||
if len(samples) > tb.capacity {
|
|
||||||
samples = samples[len(samples)-tb.capacity:]
|
|
||||||
}
|
|
||||||
tb.samples = samples
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.series[k] = s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// systemMetrics holds whole-host time series (cpu, mem, netUp, etc.) fed by
|
|
||||||
// ServerService.RefreshStatus every 2s. nodeMetrics holds per-node CPU/Mem fed
|
|
||||||
// by NodeHeartbeatJob. xrayMetrics holds xray expvar series. Only systemMetrics
|
|
||||||
// is persisted; the others rebuild from live connections.
|
|
||||||
var (
|
|
||||||
systemMetrics = newMetricHistory()
|
|
||||||
nodeMetrics = newMetricHistory()
|
|
||||||
xrayMetrics = newMetricHistory()
|
|
||||||
)
|
|
||||||
|
|
||||||
// SystemMetricKeys lists the metric names ServerService writes on every status
|
|
||||||
// sample. Exposed for documentation/test purposes; the controller validates
|
|
||||||
// incoming names against an allow-list.
|
|
||||||
var SystemMetricKeys = []string{
|
|
||||||
"cpu", "mem", "swap", "netUp", "netDown", "pktUp", "pktDown", "diskRead", "diskWrite", "diskUsage", "tcpCount", "udpCount", "online", "load1", "load5", "load15",
|
|
||||||
}
|
|
||||||
|
|
||||||
// NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes.
|
// NodeMetricKeys lists the per-node metric names NodeHeartbeatJob writes.
|
||||||
var NodeMetricKeys = []string{"cpu", "mem", "netUp", "netDown"}
|
var NodeMetricKeys = []string{"cpu", "mem", "netUp", "netDown"}
|
||||||
|
|
||||||
// XrayMetricKeys lists series sourced from xray's /debug/vars expvar endpoint.
|
|
||||||
var XrayMetricKeys = []string{
|
|
||||||
"xrAlloc", "xrSys", "xrHeapObjects", "xrNumGC", "xrPauseNs",
|
|
||||||
}
|
|
||||||
|
|
||||||
// systemMetricsStorePath is where the host time-series is persisted between
|
|
||||||
// restarts. It lives next to the database so a single volume mount carries both.
|
|
||||||
func systemMetricsStorePath() string {
|
|
||||||
return filepath.Join(config.GetDBFolderPath(), "system_metrics.gob")
|
|
||||||
}
|
|
||||||
|
|
||||||
// PersistSystemMetrics writes the host time-series to disk via a temp file +
|
|
||||||
// rename so a crash mid-write can't corrupt the previous snapshot. Called on a
|
|
||||||
// timer and at shutdown.
|
|
||||||
func PersistSystemMetrics() error {
|
|
||||||
path := systemMetricsStorePath()
|
|
||||||
tmp := path + ".tmp"
|
|
||||||
f, err := os.Create(tmp)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := gob.NewEncoder(f).Encode(systemMetrics.snapshot()); err != nil {
|
|
||||||
f.Close()
|
|
||||||
os.Remove(tmp)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := f.Close(); err != nil {
|
|
||||||
os.Remove(tmp)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return os.Rename(tmp, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// RestoreSystemMetrics loads a previously persisted host time-series on startup.
|
|
||||||
// A missing file is not an error (first boot). A pre-tier flat snapshot is
|
|
||||||
// migrated by replaying its samples through the rollup.
|
|
||||||
func RestoreSystemMetrics() {
|
|
||||||
path := systemMetricsStorePath()
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
if !os.IsNotExist(err) {
|
|
||||||
logger.Warning("restore system metrics failed:", err)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var data map[string]persistedSeries
|
|
||||||
decErr := gob.NewDecoder(f).Decode(&data)
|
|
||||||
f.Close()
|
|
||||||
if decErr == nil {
|
|
||||||
systemMetrics.restore(data)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if migrateLegacySystemMetrics(path) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
logger.Warning("decode system metrics failed:", decErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
// migrateLegacySystemMetrics loads a pre-tier flat snapshot
|
|
||||||
// (map[string][]MetricSample) and replays it through append so the new tiers are
|
|
||||||
// seeded from the existing history instead of starting empty.
|
|
||||||
func migrateLegacySystemMetrics(path string) bool {
|
|
||||||
f, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
var legacy map[string][]MetricSample
|
|
||||||
if err := gob.NewDecoder(f).Decode(&legacy); err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for metric, samples := range legacy {
|
|
||||||
for _, p := range samples {
|
|
||||||
systemMetrics.append(metric, time.Unix(p.T, 0), p.V)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -144,21 +144,6 @@ func TestAggregateLongSpanUsesCoarseTier(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSnapshotRestoreRoundTrip(t *testing.T) {
|
|
||||||
h := newMetricHistory()
|
|
||||||
now := time.Now().Unix()
|
|
||||||
for i := range int64(10) {
|
|
||||||
h.append("cpu", time.Unix(now-(9-i)*2, 0), float64(i))
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 := newMetricHistory()
|
|
||||||
h2.restore(h.snapshot())
|
|
||||||
|
|
||||||
if out := h2.aggregate("cpu", 2, 60); len(out) == 0 {
|
|
||||||
t.Fatalf("expected restored series to aggregate")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAggregateMissingMetricIsEmpty(t *testing.T) {
|
func TestAggregateMissingMetricIsEmpty(t *testing.T) {
|
||||||
h := newMetricHistory()
|
h := newMetricHistory()
|
||||||
if out := h.aggregate("nope", 2, 60); len(out) != 0 {
|
if out := h.aggregate("nope", 2, 60); len(out) != 0 {
|
||||||
|
|||||||
@@ -363,7 +363,9 @@ func (s *NodeService) GetViewById(id int) (*NodeView, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return toNodeView(n), nil
|
view := toNodeView(n)
|
||||||
|
annotateNodeTypes([]*NodeView{view})
|
||||||
|
return view, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// NodeExists reports whether a node with the given id exists on this panel.
|
// NodeExists reports whether a node with the given id exists on this panel.
|
||||||
@@ -566,7 +568,10 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" {
|
// Agent nodes authenticate with their agent token, never a panel API
|
||||||
|
// token — the requirement below must not make them uneditable.
|
||||||
|
isAgent := req.NodeType == "agent" || s.IsAgentManaged(id)
|
||||||
|
if !isAgent && apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" {
|
||||||
return common.NewError("apiToken is required unless mtls is enabled")
|
return common.NewError("apiToken is required unless mtls is enabled")
|
||||||
}
|
}
|
||||||
updates := map[string]any{
|
updates := map[string]any{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||||
)
|
)
|
||||||
@@ -25,32 +26,38 @@ type NodeView struct {
|
|||||||
InboundSyncMode string `json:"inboundSyncMode" example:"all"`
|
InboundSyncMode string `json:"inboundSyncMode" example:"all"`
|
||||||
InboundTags []string `json:"inboundTags" example:"[\"in-443-tcp\"]"`
|
InboundTags []string `json:"inboundTags" example:"[\"in-443-tcp\"]"`
|
||||||
OutboundTag string `json:"outboundTag" example:"direct"`
|
OutboundTag string `json:"outboundTag" example:"direct"`
|
||||||
Guid string `json:"guid" example:"node-guid"`
|
// NodeType is "agent" for lightweight agent-managed nodes (an agent is
|
||||||
Status string `json:"status" example:"online"`
|
// bound to the node) and "traditional" otherwise. It is derived from the
|
||||||
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"`
|
// agent binding, not stored on the node row; read paths that serve the
|
||||||
LatencyMs int `json:"latencyMs" example:"42"`
|
// panel UI annotate it via annotateNodeTypes. Empty on views built
|
||||||
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
|
// outside those paths.
|
||||||
PanelVersion string `json:"panelVersion" example:"v3.x.x"`
|
NodeType string `json:"nodeType,omitempty" example:"traditional"`
|
||||||
CpuPct float64 `json:"cpuPct" example:"12.5"`
|
Guid string `json:"guid" example:"node-guid"`
|
||||||
MemPct float64 `json:"memPct" example:"45.2"`
|
Status string `json:"status" example:"online"`
|
||||||
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
|
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"`
|
||||||
NetUp uint64 `json:"netUp" example:"2097152"`
|
LatencyMs int `json:"latencyMs" example:"42"`
|
||||||
NetDown uint64 `json:"netDown" example:"1048576"`
|
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
|
||||||
LastError string `json:"lastError" example:""`
|
PanelVersion string `json:"panelVersion" example:"v3.x.x"`
|
||||||
XrayState string `json:"xrayState" example:"running"`
|
CpuPct float64 `json:"cpuPct" example:"12.5"`
|
||||||
XrayError string `json:"xrayError" example:""`
|
MemPct float64 `json:"memPct" example:"45.2"`
|
||||||
ConfigDirty bool `json:"configDirty" example:"false"`
|
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
|
||||||
ConfigDirtyAt int64 `json:"configDirtyAt" example:"0"`
|
NetUp uint64 `json:"netUp" example:"2097152"`
|
||||||
InboundCount int `json:"inboundCount" example:"3"`
|
NetDown uint64 `json:"netDown" example:"1048576"`
|
||||||
ClientCount int `json:"clientCount" example:"25"`
|
LastError string `json:"lastError" example:""`
|
||||||
OnlineCount int `json:"onlineCount" example:"5"`
|
XrayState string `json:"xrayState" example:"running"`
|
||||||
ActiveCount int `json:"activeCount" example:"20"`
|
XrayError string `json:"xrayError" example:""`
|
||||||
DisabledCount int `json:"disabledCount" example:"2"`
|
ConfigDirty bool `json:"configDirty" example:"false"`
|
||||||
DepletedCount int `json:"depletedCount" example:"1"`
|
ConfigDirtyAt int64 `json:"configDirtyAt" example:"0"`
|
||||||
ParentGuid string `json:"parentGuid,omitempty" example:""`
|
InboundCount int `json:"inboundCount" example:"3"`
|
||||||
Transitive bool `json:"transitive,omitempty" example:"false"`
|
ClientCount int `json:"clientCount" example:"25"`
|
||||||
CreatedAt int64 `json:"createdAt" example:"1700000000"`
|
OnlineCount int `json:"onlineCount" example:"5"`
|
||||||
UpdatedAt int64 `json:"updatedAt" example:"1700003600"`
|
ActiveCount int `json:"activeCount" example:"20"`
|
||||||
|
DisabledCount int `json:"disabledCount" example:"2"`
|
||||||
|
DepletedCount int `json:"depletedCount" example:"1"`
|
||||||
|
ParentGuid string `json:"parentGuid,omitempty" example:""`
|
||||||
|
Transitive bool `json:"transitive,omitempty" example:"false"`
|
||||||
|
CreatedAt int64 `json:"createdAt" example:"1700000000"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt" example:"1700003600"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func toNodeView(n *model.Node) *NodeView {
|
func toNodeView(n *model.Node) *NodeView {
|
||||||
@@ -110,6 +117,45 @@ func toNodeViews(nodes []*model.Node) []*NodeView {
|
|||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// agentManagedNodeIDs returns the set of node ids with at least one agent
|
||||||
|
// bound to them — the definition of an "agent node" in this codebase (the
|
||||||
|
// heartbeat job's probe skip uses the same test).
|
||||||
|
func agentManagedNodeIDs() map[int]bool {
|
||||||
|
ids := map[int]bool{}
|
||||||
|
var rows []int
|
||||||
|
_ = database.GetDB().Model(&model.AgentNode{}).
|
||||||
|
Where("node_id IS NOT NULL").
|
||||||
|
Distinct().
|
||||||
|
Pluck("node_id", &rows).Error
|
||||||
|
for _, id := range rows {
|
||||||
|
ids[id] = true
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAgentManaged reports whether the node has an agent bound to it.
|
||||||
|
func (s *NodeService) IsAgentManaged(id int) bool {
|
||||||
|
var count int64
|
||||||
|
_ = database.GetDB().Model(&model.AgentNode{}).Where("node_id = ?", id).Count(&count).Error
|
||||||
|
return count > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// annotateNodeTypes fills NodeType on views from the agent bindings so the
|
||||||
|
// UI can tell agent nodes apart from traditional panel nodes when editing.
|
||||||
|
func annotateNodeTypes(views []*NodeView) {
|
||||||
|
if len(views) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
agents := agentManagedNodeIDs()
|
||||||
|
for _, v := range views {
|
||||||
|
if agents[v.Id] {
|
||||||
|
v.NodeType = "agent"
|
||||||
|
} else {
|
||||||
|
v.NodeType = "traditional"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NodeMutationRequest is the node write/probe contract. ApiToken is accepted
|
// NodeMutationRequest is the node write/probe contract. ApiToken is accepted
|
||||||
// only as input. On update, nil means keep the stored token; replacement and
|
// only as input. On update, nil means keep the stored token; replacement and
|
||||||
// clearing are explicit and mutually exclusive.
|
// clearing are explicit and mutually exclusive.
|
||||||
|
|||||||
@@ -162,7 +162,9 @@ func (s *NodeService) GetNodeTreeView() ([]*NodeView, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return toNodeViews(nodes), nil
|
views := toNodeViews(nodes)
|
||||||
|
annotateNodeTypes(views)
|
||||||
|
return views, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// recountByGuid recomputes InboundCount/OnlineCount/DepletedCount for every node
|
// recountByGuid recomputes InboundCount/OnlineCount/DepletedCount for every node
|
||||||
|
|||||||
@@ -24,6 +24,13 @@ func TestIsNewerVersion(t *testing.T) {
|
|||||||
{"v2.9.3", "2.9.3", false},
|
{"v2.9.3", "2.9.3", false},
|
||||||
{"v2.9.2", "2.9.3", false},
|
{"v2.9.2", "2.9.3", false},
|
||||||
{"v3.0.0", "2.9.3", true},
|
{"v3.0.0", "2.9.3", true},
|
||||||
|
// Fork release: the Gitea tag carries the fork suffix while the binary
|
||||||
|
// reports the same suffix (injected via -ldflags -X buildVersion), so
|
||||||
|
// the pair must compare as up-to-date, not as a phantom update. The
|
||||||
|
// pre-fix binary reported plain "3.7.0" here and compared newer.
|
||||||
|
{"v3.7.0-agent.1", "3.7.0-agent.1", false},
|
||||||
|
{"v3.7.0-agent.1", "3.7.0", true},
|
||||||
|
{"v3.7.0-agent.2", "3.7.0-agent.1", true},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"bytes"
|
"bytes"
|
||||||
"cmp"
|
|
||||||
"context"
|
"context"
|
||||||
"crypto/ecdh"
|
"crypto/ecdh"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
@@ -16,7 +14,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
stdnet "net"
|
stdnet "net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -26,13 +23,10 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
@@ -244,10 +238,11 @@ func (s *ServerService) isFail2banInstalled() bool {
|
|||||||
return s.fail2banInstalled
|
return s.fail2banInstalled
|
||||||
}
|
}
|
||||||
|
|
||||||
// RefreshStatus collects a new system snapshot, stores it as LastStatus, and
|
// RefreshStatus collects a new system snapshot and stores it as LastStatus.
|
||||||
// appends it to the system-metrics time series. Returns the new snapshot (may
|
// Returns the new snapshot (may be nil if collection failed). Called by the
|
||||||
// be nil if collection failed). Called by the background ticker; the caller is
|
// background ticker; the caller is responsible for any side effects
|
||||||
// responsible for any side effects (websocket broadcast, xray metrics sample).
|
// (websocket broadcast). The realtime status served to the UI comes from
|
||||||
|
// this snapshot — the removed system-history series used to be fed here too.
|
||||||
func (s *ServerService) RefreshStatus() *Status {
|
func (s *ServerService) RefreshStatus() *Status {
|
||||||
next := s.GetStatus(s.LastStatus())
|
next := s.GetStatus(s.LastStatus())
|
||||||
if next == nil {
|
if next == nil {
|
||||||
@@ -256,76 +251,9 @@ func (s *ServerService) RefreshStatus() *Status {
|
|||||||
s.lastStatusMu.Lock()
|
s.lastStatusMu.Lock()
|
||||||
s.lastStatus = next
|
s.lastStatus = next
|
||||||
s.lastStatusMu.Unlock()
|
s.lastStatusMu.Unlock()
|
||||||
s.AppendStatusSample(time.Now(), next)
|
|
||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDefaultLogOutboundTags scans the default Xray config for freedom and
|
|
||||||
// blackhole outbound tags so /getXrayLogs can colour-code log lines without
|
|
||||||
// the controller re-doing the JSON walk. Falls back to the historical
|
|
||||||
// "direct"/"blocked" defaults when the config can't be read.
|
|
||||||
func (s *ServerService) GetDefaultLogOutboundTags() (freedoms, blackholes []string) {
|
|
||||||
config, err := s.settingService.GetDefaultXrayConfig()
|
|
||||||
if err == nil && config != nil {
|
|
||||||
if cfgMap, ok := config.(map[string]any); ok {
|
|
||||||
if outbounds, ok := cfgMap["outbounds"].([]any); ok {
|
|
||||||
for _, outbound := range outbounds {
|
|
||||||
obMap, ok := outbound.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
tag, _ := obMap["tag"].(string)
|
|
||||||
if tag == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
switch obMap["protocol"] {
|
|
||||||
case "freedom":
|
|
||||||
freedoms = append(freedoms, tag)
|
|
||||||
case "blackhole":
|
|
||||||
blackholes = append(blackholes, tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(freedoms) == 0 {
|
|
||||||
freedoms = []string{"direct"}
|
|
||||||
}
|
|
||||||
if len(blackholes) == 0 {
|
|
||||||
blackholes = []string{"blocked"}
|
|
||||||
}
|
|
||||||
return freedoms, blackholes
|
|
||||||
}
|
|
||||||
|
|
||||||
// AggregateCpuHistory returns up to maxPoints averaged buckets of size bucketSeconds.
|
|
||||||
// Kept for back-compat with the original /panel/api/server/cpuHistory/:bucket route;
|
|
||||||
// the response key is "cpu" (not "v") so legacy consumers parse unchanged.
|
|
||||||
func (s *ServerService) AggregateCpuHistory(bucketSeconds int, maxPoints int) []map[string]any {
|
|
||||||
out := systemMetrics.aggregate("cpu", bucketSeconds, maxPoints)
|
|
||||||
for _, p := range out {
|
|
||||||
p["cpu"] = p["v"]
|
|
||||||
delete(p, "v")
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// AggregateSystemMetric returns up to maxPoints averaged buckets for any
|
|
||||||
// known system metric (see SystemMetricKeys). Output points have keys
|
|
||||||
// {"t": unixSec, "v": value}; the caller decides how to format the value.
|
|
||||||
func (s *ServerService) AggregateSystemMetric(metric string, bucketSeconds int, maxPoints int) []map[string]any {
|
|
||||||
return systemMetrics.aggregate(metric, bucketSeconds, maxPoints)
|
|
||||||
}
|
|
||||||
|
|
||||||
type LogEntry struct {
|
|
||||||
DateTime time.Time
|
|
||||||
FromAddress string
|
|
||||||
ToAddress string
|
|
||||||
Inbound string
|
|
||||||
Outbound string
|
|
||||||
Email string
|
|
||||||
Event int
|
|
||||||
}
|
|
||||||
|
|
||||||
func getPublicIP(url string) string {
|
func getPublicIP(url string) string {
|
||||||
client := &http.Client{
|
client := &http.Client{
|
||||||
Timeout: 3 * time.Second,
|
Timeout: 3 * time.Second,
|
||||||
@@ -621,52 +549,6 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
|
|||||||
return status
|
return status
|
||||||
}
|
}
|
||||||
|
|
||||||
// AppendCpuSample is preserved for callers that only have the CPU number.
|
|
||||||
// New callers should prefer AppendStatusSample which writes the full set.
|
|
||||||
func (s *ServerService) AppendCpuSample(t time.Time, v float64) {
|
|
||||||
systemMetrics.append("cpu", t, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AppendStatusSample writes one tick of every metric we keep — CPU, memory
|
|
||||||
// percent, network throughput (bytes/s), online client count, and the three
|
|
||||||
// load averages. Called by RefreshStatus on the same @2s cadence as
|
|
||||||
// AppendCpuSample, so all series stay aligned.
|
|
||||||
func (s *ServerService) AppendStatusSample(t time.Time, status *Status) {
|
|
||||||
if status == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
systemMetrics.append("cpu", t, status.Cpu)
|
|
||||||
if status.Mem.Total > 0 {
|
|
||||||
systemMetrics.append("mem", t, float64(status.Mem.Current)*100.0/float64(status.Mem.Total))
|
|
||||||
}
|
|
||||||
if status.Swap.Total > 0 {
|
|
||||||
systemMetrics.append("swap", t, float64(status.Swap.Current)*100.0/float64(status.Swap.Total))
|
|
||||||
} else {
|
|
||||||
systemMetrics.append("swap", t, 0)
|
|
||||||
}
|
|
||||||
systemMetrics.append("netUp", t, float64(status.NetIO.Up))
|
|
||||||
systemMetrics.append("netDown", t, float64(status.NetIO.Down))
|
|
||||||
systemMetrics.append("diskRead", t, float64(status.DiskIO.Read))
|
|
||||||
systemMetrics.append("diskWrite", t, float64(status.DiskIO.Write))
|
|
||||||
if status.Disk.Total > 0 {
|
|
||||||
systemMetrics.append("diskUsage", t, float64(status.Disk.Current)*100.0/float64(status.Disk.Total))
|
|
||||||
}
|
|
||||||
systemMetrics.append("pktUp", t, float64(status.NetIO.PktUp))
|
|
||||||
systemMetrics.append("pktDown", t, float64(status.NetIO.PktDown))
|
|
||||||
systemMetrics.append("tcpCount", t, float64(status.TcpCount))
|
|
||||||
systemMetrics.append("udpCount", t, float64(status.UdpCount))
|
|
||||||
online := 0
|
|
||||||
if process := currentXrayProcess(); process != nil && process.IsRunning() {
|
|
||||||
online = len(process.GetOnlineClients())
|
|
||||||
}
|
|
||||||
systemMetrics.append("online", t, float64(online))
|
|
||||||
if len(status.Loads) >= 3 {
|
|
||||||
systemMetrics.append("load1", t, status.Loads[0])
|
|
||||||
systemMetrics.append("load5", t, status.Loads[1])
|
|
||||||
systemMetrics.append("load15", t, status.Loads[2])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ServerService) sampleCPUUtilization() (float64, error) {
|
func (s *ServerService) sampleCPUUtilization() (float64, error) {
|
||||||
// Try native platform-specific CPU implementation first (Windows, Linux, macOS)
|
// Try native platform-specific CPU implementation first (Windows, Linux, macOS)
|
||||||
if pct, err := sys.CPUPercentRaw(); err == nil {
|
if pct, err := sys.CPUPercentRaw(); err == nil {
|
||||||
@@ -770,306 +652,6 @@ func (s *ServerService) RestartXrayService() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ServerService) GetLogs(count string, level string, syslog string) []string {
|
|
||||||
c, _ := strconv.Atoi(count)
|
|
||||||
var lines []string
|
|
||||||
|
|
||||||
if syslog == "true" {
|
|
||||||
// Check if running on Windows - journalctl is not available
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return []string{"Syslog is not supported on Windows. Please use application logs instead by unchecking the 'Syslog' option."}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate and sanitize count parameter
|
|
||||||
countInt, err := strconv.Atoi(count)
|
|
||||||
if err != nil || countInt < 1 || countInt > 10000 {
|
|
||||||
return []string{"Invalid count parameter - must be a number between 1 and 10000"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate level parameter - only allow valid syslog levels
|
|
||||||
validLevels := map[string]bool{
|
|
||||||
"0": true, "emerg": true,
|
|
||||||
"1": true, "alert": true,
|
|
||||||
"2": true, "crit": true,
|
|
||||||
"3": true, "err": true,
|
|
||||||
"4": true, "warning": true,
|
|
||||||
"5": true, "notice": true,
|
|
||||||
"6": true, "info": true,
|
|
||||||
"7": true, "debug": true,
|
|
||||||
}
|
|
||||||
if !validLevels[level] {
|
|
||||||
return []string{"Invalid level parameter - must be a valid syslog level"}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use hardcoded command with validated parameters
|
|
||||||
cmd := exec.CommandContext(context.Background(), "journalctl", "-u", "x-ui", "--no-pager", "-n", strconv.Itoa(countInt), "-p", level)
|
|
||||||
var out bytes.Buffer
|
|
||||||
cmd.Stdout = &out
|
|
||||||
err = cmd.Run()
|
|
||||||
if err != nil {
|
|
||||||
return []string{"Failed to run journalctl command! Make sure systemd is available and x-ui service is registered."}
|
|
||||||
}
|
|
||||||
lines = strings.Split(out.String(), "\n")
|
|
||||||
} else {
|
|
||||||
lines = logger.GetLogs(c, level)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseAccessLogFields extracts the structured fields from one Xray access-log
|
|
||||||
// line. Lines are attacker-influenced (a client's requested destination lands in
|
|
||||||
// the log verbatim) and may be truncated, so every positional lookup is length
|
|
||||||
// guarded: a malformed line yields a partial entry rather than panicking.
|
|
||||||
func parseAccessLogFields(line string) LogEntry {
|
|
||||||
var entry LogEntry
|
|
||||||
parts := strings.Fields(line)
|
|
||||||
|
|
||||||
for i, part := range parts {
|
|
||||||
|
|
||||||
if i == 0 && len(parts) > 1 {
|
|
||||||
dateTime, err := time.ParseInLocation("2006/01/02 15:04:05.999999", parts[0]+" "+parts[1], time.Local)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entry.DateTime = dateTime.UTC()
|
|
||||||
}
|
|
||||||
|
|
||||||
if part == "from" && i+1 < len(parts) {
|
|
||||||
entry.FromAddress = strings.TrimLeft(parts[i+1], "/")
|
|
||||||
} else if part == "accepted" && i+1 < len(parts) {
|
|
||||||
entry.ToAddress = strings.TrimLeft(parts[i+1], "/")
|
|
||||||
} else if strings.HasPrefix(part, "[") {
|
|
||||||
entry.Inbound = part[1:]
|
|
||||||
} else if strings.HasSuffix(part, "]") {
|
|
||||||
entry.Outbound = part[:len(part)-1]
|
|
||||||
} else if part == "email:" && i+1 < len(parts) {
|
|
||||||
entry.Email = parts[i+1]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return entry
|
|
||||||
}
|
|
||||||
|
|
||||||
// PeerActivity is one peer's live embedded-Device-reported state, the
|
|
||||||
// counterpart of an Xray access-log entry: a tunnel logs no requests, only
|
|
||||||
// handshakes and bytes.
|
|
||||||
type PeerActivity struct {
|
|
||||||
Interface string `json:"interface" example:"awg1"`
|
|
||||||
Tag string `json:"tag" example:"inbound-51820"`
|
|
||||||
InboundId int `json:"inboundId" example:"1"`
|
|
||||||
Email string `json:"email" example:"peer@example.com"`
|
|
||||||
Endpoint string `json:"endpoint" example:"203.0.113.9:51820"`
|
|
||||||
AllowedIPs string `json:"allowedIPs" example:"10.8.1.2/32"`
|
|
||||||
// Handshake is unix milliseconds, 0 when the peer has never connected.
|
|
||||||
Handshake int64 `json:"handshake" example:"1735732800000"`
|
|
||||||
Up int64 `json:"up" example:"1048576"`
|
|
||||||
Down int64 `json:"down" example:"4194304"`
|
|
||||||
Online bool `json:"online" example:"true"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// amneziawgOnlineWindow mirrors the standard WireGuard convention (and this
|
|
||||||
// fork's own prior kernel-module behavior): a handshake this recent counts
|
|
||||||
// as online.
|
|
||||||
const amneziawgOnlineWindow = 180 * time.Second
|
|
||||||
|
|
||||||
// AmneziaWGLogs is what the overview's AmneziaWG log view renders: the live
|
|
||||||
// per-peer activity of every running embedded interface, plus the panel's
|
|
||||||
// own recent AmneziaWG lifecycle log lines that explain a peer being absent
|
|
||||||
// from Peers at all.
|
|
||||||
type AmneziaWGLogs struct {
|
|
||||||
Peers []PeerActivity `json:"peers"`
|
|
||||||
Events []string `json:"events" example:"[\"2025/01/01 12:00:00 amneziawg: started interface awg1 for inbound 1\"]"`
|
|
||||||
Running bool `json:"running" example:"true"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// amneziawgEventMarker selects the panel's own AmneziaWG log lines: every
|
|
||||||
// logger call in internal/amneziawg, internal/amneziawgnet and their jobs
|
|
||||||
// prefixes its message with it.
|
|
||||||
const amneziawgEventMarker = "amneziawg"
|
|
||||||
|
|
||||||
// amneziawgLogActivity gathers live PeerActivity rows across every enabled,
|
|
||||||
// non-node-hosted AmneziaWG inbound, newest handshake first. An inbound
|
|
||||||
// amneziawgnet has no running Device for yet (not reconciled, disabled,
|
|
||||||
// errored) contributes no rows -- not reported as an error, since the
|
|
||||||
// caller (GetAmneziaWGLogs) already has a device-agnostic Running flag from
|
|
||||||
// amneziawgnet.GetManager().HasRunning() for that.
|
|
||||||
// clampUint64ToInt64 saturates at math.MaxInt64 instead of wrapping negative,
|
|
||||||
// for a live uint64 byte counter (amneziawgnet's own UAPI-dump snapshot, not
|
|
||||||
// a DB-accumulated total) going into an int64 API field -- unreachable in
|
|
||||||
// practice at real traffic volumes, but a silent negative value would be
|
|
||||||
// worse than a saturated one if it were ever hit.
|
|
||||||
func clampUint64ToInt64(v uint64) int64 {
|
|
||||||
if v > math.MaxInt64 {
|
|
||||||
return math.MaxInt64
|
|
||||||
}
|
|
||||||
return int64(v)
|
|
||||||
}
|
|
||||||
|
|
||||||
func amneziawgLogActivity() []PeerActivity {
|
|
||||||
var inbounds []*model.Inbound
|
|
||||||
if err := database.GetDB().
|
|
||||||
Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true).
|
|
||||||
Find(&inbounds).Error; err != nil {
|
|
||||||
logger.Warning("amneziawg logs: list inbounds failed:", err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
var out []PeerActivity
|
|
||||||
for _, inbound := range inbounds {
|
|
||||||
inst, ok := amneziawg.InstanceFromInbound(inbound)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
diag := amneziawgnet.Diagnose(inbound.Id, inst.Peers)
|
|
||||||
if !diag.Running {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, cd := range diag.Clients {
|
|
||||||
var handshakeMs int64
|
|
||||||
online := false
|
|
||||||
if !cd.LastHandshake.IsZero() {
|
|
||||||
handshakeMs = cd.LastHandshake.UnixMilli()
|
|
||||||
online = now.Sub(cd.LastHandshake) < amneziawgOnlineWindow
|
|
||||||
}
|
|
||||||
out = append(out, PeerActivity{
|
|
||||||
Interface: inst.InterfaceName,
|
|
||||||
Tag: inbound.Tag,
|
|
||||||
InboundId: inbound.Id,
|
|
||||||
Email: cd.Email,
|
|
||||||
Endpoint: cd.Endpoint,
|
|
||||||
AllowedIPs: cd.AllowedIPs,
|
|
||||||
Handshake: handshakeMs,
|
|
||||||
Up: clampUint64ToInt64(cd.RxBytes),
|
|
||||||
Down: clampUint64ToInt64(cd.TxBytes),
|
|
||||||
Online: online,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
slices.SortFunc(out, func(a, b PeerActivity) int {
|
|
||||||
if a.Handshake != b.Handshake {
|
|
||||||
return cmp.Compare(b.Handshake, a.Handshake)
|
|
||||||
}
|
|
||||||
return strings.Compare(a.Email, b.Email)
|
|
||||||
})
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetAmneziaWGLogs returns at most count peer rows and count event lines,
|
|
||||||
// optionally narrowed to rows whose text contains filter (case-insensitive),
|
|
||||||
// mirroring GetXrayLogs' own count+filter contract.
|
|
||||||
func (s *ServerService) GetAmneziaWGLogs(count string, filter string) *AmneziaWGLogs {
|
|
||||||
limit, err := strconv.Atoi(count)
|
|
||||||
if err != nil || limit < 1 || limit > 10000 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
needle := strings.ToLower(strings.TrimSpace(filter))
|
|
||||||
|
|
||||||
logs := &AmneziaWGLogs{Peers: []PeerActivity{}, Events: []string{}, Running: amneziawgnet.GetManager().HasRunning()}
|
|
||||||
|
|
||||||
for _, peer := range amneziawgLogActivity() {
|
|
||||||
if len(logs.Peers) >= limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if needle != "" && !strings.Contains(strings.ToLower(peer.Email+" "+peer.Tag+" "+peer.Interface+" "+peer.Endpoint+" "+peer.AllowedIPs), needle) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
logs.Peers = append(logs.Peers, peer)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, line := range logger.GetLogs(10000, "debug") {
|
|
||||||
if len(logs.Events) >= limit {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
if !strings.Contains(strings.ToLower(line), amneziawgEventMarker) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if needle != "" && !strings.Contains(strings.ToLower(line), needle) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
logs.Events = append(logs.Events, line)
|
|
||||||
}
|
|
||||||
return logs
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ServerService) GetXrayLogs(
|
|
||||||
count string,
|
|
||||||
filter string,
|
|
||||||
showDirect string,
|
|
||||||
showBlocked string,
|
|
||||||
showProxy string,
|
|
||||||
freedoms []string,
|
|
||||||
blackholes []string,
|
|
||||||
) []LogEntry {
|
|
||||||
const (
|
|
||||||
Direct = iota
|
|
||||||
Blocked
|
|
||||||
Proxied
|
|
||||||
)
|
|
||||||
|
|
||||||
countInt, _ := strconv.Atoi(count)
|
|
||||||
var entries []LogEntry
|
|
||||||
|
|
||||||
pathToAccessLog, err := xray.GetAccessLogPath()
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := os.Open(pathToAccessLog)
|
|
||||||
if err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
|
|
||||||
if line == "" || strings.Contains(line, "api -> api") {
|
|
||||||
// skipping empty lines and api calls
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if filter != "" && !strings.Contains(line, filter) {
|
|
||||||
// applying filter if it's not empty
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
entry := parseAccessLogFields(line)
|
|
||||||
|
|
||||||
if logEntryContains(line, freedoms) {
|
|
||||||
if showDirect == "false" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entry.Event = Direct
|
|
||||||
} else if logEntryContains(line, blackholes) {
|
|
||||||
if showBlocked == "false" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entry.Event = Blocked
|
|
||||||
} else {
|
|
||||||
if showProxy == "false" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
entry.Event = Proxied
|
|
||||||
}
|
|
||||||
|
|
||||||
entries = append(entries, entry)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := scanner.Err(); err != nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(entries) > countInt {
|
|
||||||
entries = entries[len(entries)-countInt:]
|
|
||||||
}
|
|
||||||
|
|
||||||
return entries
|
|
||||||
}
|
|
||||||
|
|
||||||
// isVirtualInterface returns true for loopback and virtual/tunnel interfaces
|
// isVirtualInterface returns true for loopback and virtual/tunnel interfaces
|
||||||
// that should be excluded from network traffic statistics.
|
// that should be excluded from network traffic statistics.
|
||||||
func isVirtualInterface(name string) bool {
|
func isVirtualInterface(name string) bool {
|
||||||
@@ -1098,15 +680,6 @@ func isVirtualInterface(name string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func logEntryContains(line string, suffixes []string) bool {
|
|
||||||
for _, sfx := range suffixes {
|
|
||||||
if strings.Contains(line, sfx+"]") {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *ServerService) GetConfigJson() (any, error) {
|
func (s *ServerService) GetConfigJson() (any, error) {
|
||||||
config, err := s.xrayService.GetXrayConfig()
|
config, err := s.xrayService.GetXrayConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -928,7 +928,10 @@ func (s *SettingService) BuildSubURIBase(host string) string {
|
|||||||
if subTLS {
|
if subTLS {
|
||||||
scheme = "https"
|
scheme = "https"
|
||||||
}
|
}
|
||||||
if (subPort == 443 && subTLS) || (subPort == 80 && !subTLS) {
|
// subPort 0 is the explicit "no port in links" setting (reverse proxy /
|
||||||
|
// tunnel on the scheme's standard port); the sub server keeps binding
|
||||||
|
// the default port — only the generated links omit the port.
|
||||||
|
if subPort <= 0 || (subPort == 443 && subTLS) || (subPort == 80 && !subTLS) {
|
||||||
return scheme + "://" + subDomain
|
return scheme + "://" + subDomain
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s://%s:%d", scheme, subDomain, subPort)
|
return fmt.Sprintf("%s://%s:%d", scheme, subDomain, subPort)
|
||||||
|
|||||||
@@ -1,330 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"time"
|
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
type xrayMetricsState struct {
|
|
||||||
Enabled bool `json:"enabled"`
|
|
||||||
Listen string `json:"listen"`
|
|
||||||
Reason string `json:"reason,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ObsTagSnapshot struct {
|
|
||||||
Tag string `json:"tag"`
|
|
||||||
Alive bool `json:"alive"`
|
|
||||||
Delay int64 `json:"delay"`
|
|
||||||
LastSeenTime int64 `json:"lastSeenTime"`
|
|
||||||
LastTryTime int64 `json:"lastTryTime"`
|
|
||||||
UpdatedAt int64 `json:"updatedAt"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// eventBus is the shared bus for publishing observatory state-change events.
|
|
||||||
// Set once during startup via SetEventBus; nil when no bus is configured.
|
|
||||||
var eventBus *eventbus.Bus
|
|
||||||
|
|
||||||
// SetEventBus assigns the global event bus used by applyObservatory to publish
|
|
||||||
// outbound health transitions. Must be called once during startup before any
|
|
||||||
// Sample tick runs.
|
|
||||||
func SetEventBus(b *eventbus.Bus) { eventBus = b }
|
|
||||||
|
|
||||||
type XrayMetricsService struct {
|
|
||||||
settingService SettingService
|
|
||||||
|
|
||||||
mu sync.RWMutex
|
|
||||||
state xrayMetricsState
|
|
||||||
client *http.Client
|
|
||||||
obsByTag map[string]ObsTagSnapshot
|
|
||||||
health map[string]outboundHealth
|
|
||||||
}
|
|
||||||
|
|
||||||
// outboundHealth debounces observatory flapping. Xray flips an outbound's
|
|
||||||
// alive flag on a single failed probe, so raw transitions produce a storm of
|
|
||||||
// down/up notifications on a flaky link. We instead require failStreak to reach
|
|
||||||
// the configured threshold (consecutive FAILED probes, tracked per new probe
|
|
||||||
// via lastTry) before publishing outbound.down, and only publish outbound.up
|
|
||||||
// once a down has actually been notified.
|
|
||||||
type outboundHealth struct {
|
|
||||||
lastTry int64
|
|
||||||
failStreak int
|
|
||||||
notified bool
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxObsTagLength = 128
|
|
||||||
|
|
||||||
func validObsTag(tag string) bool {
|
|
||||||
if tag == "" || len(tag) > maxObsTagLength || !utf8.ValidString(tag) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
for _, r := range tag {
|
|
||||||
if unicode.IsControl(r) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
func obsHistoryKey(tag string) string {
|
|
||||||
return "xrObs." + tag + ".delay"
|
|
||||||
}
|
|
||||||
|
|
||||||
func newXrayMetricsClient() *http.Client {
|
|
||||||
return &http.Client{Timeout: 1500 * time.Millisecond}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *XrayMetricsService) getClient() *http.Client {
|
|
||||||
s.mu.Lock()
|
|
||||||
defer s.mu.Unlock()
|
|
||||||
if s.client == nil {
|
|
||||||
s.client = newXrayMetricsClient()
|
|
||||||
}
|
|
||||||
return s.client
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *XrayMetricsService) State() xrayMetricsState {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
return s.state
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *XrayMetricsService) AggregateMetric(metric string, bucketSeconds, maxPoints int) []map[string]any {
|
|
||||||
return xrayMetrics.aggregate(metric, bucketSeconds, maxPoints)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *XrayMetricsService) ObservatorySnapshot() []ObsTagSnapshot {
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
out := make([]ObsTagSnapshot, 0, len(s.obsByTag))
|
|
||||||
for _, v := range s.obsByTag {
|
|
||||||
out = append(out, v)
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Tag < out[j].Tag })
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *XrayMetricsService) HasObservatoryTag(tag string) bool {
|
|
||||||
if !validObsTag(tag) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
s.mu.RLock()
|
|
||||||
defer s.mu.RUnlock()
|
|
||||||
_, ok := s.obsByTag[tag]
|
|
||||||
return ok
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *XrayMetricsService) AggregateObservatory(tag string, bucketSeconds, maxPoints int) []map[string]any {
|
|
||||||
if !validObsTag(tag) {
|
|
||||||
return []map[string]any{}
|
|
||||||
}
|
|
||||||
return xrayMetrics.aggregate(obsHistoryKey(tag), bucketSeconds, maxPoints)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *XrayMetricsService) discoverListen() (string, error) {
|
|
||||||
tmpl, err := s.settingService.GetXrayConfigTemplate()
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
var parsed struct {
|
|
||||||
Metrics *struct {
|
|
||||||
Listen string `json:"listen"`
|
|
||||||
} `json:"metrics"`
|
|
||||||
}
|
|
||||||
if err := json.Unmarshal([]byte(tmpl), &parsed); err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if parsed.Metrics == nil || strings.TrimSpace(parsed.Metrics.Listen) == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(parsed.Metrics.Listen), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
type rawObsEntry struct {
|
|
||||||
Alive bool `json:"alive"`
|
|
||||||
Delay int64 `json:"delay"`
|
|
||||||
LastSeenTime int64 `json:"last_seen_time"`
|
|
||||||
LastTryTime int64 `json:"last_try_time"`
|
|
||||||
OutboundTag string `json:"outbound_tag"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *XrayMetricsService) Sample(t time.Time) {
|
|
||||||
// The local xray core never runs in this build (control-plane only),
|
|
||||||
// so there is no /debug/vars endpoint to sample. Report the metrics
|
|
||||||
// feed as disabled and stay silent instead of probing a permanently
|
|
||||||
// dead endpoint on every 2s status tick.
|
|
||||||
s.setStateQuiet(xrayMetricsState{Enabled: false, Reason: "local xray core disabled in this build"})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *XrayMetricsService) sampleLocalXrayLegacy(t time.Time) {
|
|
||||||
listen, err := s.discoverListen()
|
|
||||||
if err != nil {
|
|
||||||
s.setState(xrayMetricsState{Reason: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if listen == "" {
|
|
||||||
s.setState(xrayMetricsState{Reason: "metrics block not configured in xray template"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 1500*time.Millisecond)
|
|
||||||
defer cancel()
|
|
||||||
url := fmt.Sprintf("http://%s/debug/vars", listen)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resp, err := s.getClient().Do(req)
|
|
||||||
if err != nil {
|
|
||||||
s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
s.setState(xrayMetricsState{Listen: listen, Reason: fmt.Sprintf("HTTP %d", resp.StatusCode)})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var payload struct {
|
|
||||||
MemStats struct {
|
|
||||||
HeapAlloc uint64 `json:"HeapAlloc"`
|
|
||||||
Sys uint64 `json:"Sys"`
|
|
||||||
HeapObjects uint64 `json:"HeapObjects"`
|
|
||||||
NumGC uint32 `json:"NumGC"`
|
|
||||||
PauseNs [256]uint64 `json:"PauseNs"`
|
|
||||||
} `json:"memstats"`
|
|
||||||
Observatory map[string]rawObsEntry `json:"observatory"`
|
|
||||||
}
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
|
||||||
s.setState(xrayMetricsState{Listen: listen, Reason: err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
xrayMetrics.append("xrAlloc", t, float64(payload.MemStats.HeapAlloc))
|
|
||||||
xrayMetrics.append("xrSys", t, float64(payload.MemStats.Sys))
|
|
||||||
xrayMetrics.append("xrHeapObjects", t, float64(payload.MemStats.HeapObjects))
|
|
||||||
xrayMetrics.append("xrNumGC", t, float64(payload.MemStats.NumGC))
|
|
||||||
var lastPause uint64
|
|
||||||
if payload.MemStats.NumGC > 0 {
|
|
||||||
idx := (payload.MemStats.NumGC + 255) % 256
|
|
||||||
lastPause = payload.MemStats.PauseNs[idx]
|
|
||||||
}
|
|
||||||
xrayMetrics.append("xrPauseNs", t, float64(lastPause))
|
|
||||||
|
|
||||||
s.applyObservatory(t, payload.Observatory)
|
|
||||||
s.setState(xrayMetricsState{Enabled: true, Listen: listen})
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]rawObsEntry) {
|
|
||||||
next := make(map[string]ObsTagSnapshot, len(entries))
|
|
||||||
for key, e := range entries {
|
|
||||||
tag := e.OutboundTag
|
|
||||||
if tag == "" {
|
|
||||||
tag = key
|
|
||||||
}
|
|
||||||
if !validObsTag(tag) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
snap := ObsTagSnapshot{
|
|
||||||
Tag: tag,
|
|
||||||
Alive: e.Alive,
|
|
||||||
Delay: e.Delay,
|
|
||||||
LastSeenTime: e.LastSeenTime,
|
|
||||||
LastTryTime: e.LastTryTime,
|
|
||||||
UpdatedAt: t.Unix(),
|
|
||||||
}
|
|
||||||
next[tag] = snap
|
|
||||||
xrayMetrics.append(obsHistoryKey(tag), t, float64(e.Delay))
|
|
||||||
}
|
|
||||||
|
|
||||||
threshold := 3
|
|
||||||
if v, err := s.settingService.GetOutboundDownThreshold(); err == nil && v > 0 {
|
|
||||||
threshold = v
|
|
||||||
}
|
|
||||||
|
|
||||||
s.mu.Lock()
|
|
||||||
// Debounce observatory flapping into stable down/up notifications.
|
|
||||||
if eventBus != nil {
|
|
||||||
if s.health == nil {
|
|
||||||
s.health = make(map[string]outboundHealth, len(next))
|
|
||||||
}
|
|
||||||
for tag, cur := range next {
|
|
||||||
// React only to a genuinely new probe attempt (lastTry advanced).
|
|
||||||
// The sampler polls far more often than xray probes, so counting
|
|
||||||
// samples instead of probes would trip the threshold instantly.
|
|
||||||
h := s.health[tag]
|
|
||||||
if cur.LastTryTime == 0 || cur.LastTryTime == h.lastTry {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
h.lastTry = cur.LastTryTime
|
|
||||||
if cur.Alive {
|
|
||||||
if h.notified {
|
|
||||||
eventBus.Publish(eventbus.Event{
|
|
||||||
Type: eventbus.EventOutboundUp,
|
|
||||||
Source: tag,
|
|
||||||
Data: &eventbus.OutboundHealthData{Delay: cur.Delay},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
h.failStreak = 0
|
|
||||||
h.notified = false
|
|
||||||
} else {
|
|
||||||
h.failStreak++
|
|
||||||
if h.failStreak >= threshold && !h.notified {
|
|
||||||
errMsg := ""
|
|
||||||
if cur.Delay < 0 {
|
|
||||||
errMsg = "probe failed"
|
|
||||||
}
|
|
||||||
eventBus.Publish(eventbus.Event{
|
|
||||||
Type: eventbus.EventOutboundDown,
|
|
||||||
Source: tag,
|
|
||||||
Data: &eventbus.OutboundHealthData{Delay: cur.Delay, Error: errMsg},
|
|
||||||
})
|
|
||||||
h.notified = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.health[tag] = h
|
|
||||||
}
|
|
||||||
// Forget tags that vanished from the observatory.
|
|
||||||
for tag := range s.health {
|
|
||||||
if _, ok := next[tag]; !ok {
|
|
||||||
delete(s.health, tag)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for tag := range s.obsByTag {
|
|
||||||
if _, kept := next[tag]; !kept {
|
|
||||||
xrayMetrics.drop(obsHistoryKey(tag))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.obsByTag = next
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *XrayMetricsService) setState(st xrayMetricsState) {
|
|
||||||
s.mu.Lock()
|
|
||||||
s.state = st
|
|
||||||
s.mu.Unlock()
|
|
||||||
if !st.Enabled && st.Reason != "" {
|
|
||||||
logger.Debugf("xray metrics unavailable: %s", st.Reason)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// setStateQuiet updates the metrics state without the Debug log line, for
|
|
||||||
// states that are permanent by design rather than a transient failure.
|
|
||||||
func (s *XrayMetricsService) setStateQuiet(st xrayMetricsState) {
|
|
||||||
s.mu.Lock()
|
|
||||||
s.state = st
|
|
||||||
s.mu.Unlock()
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
package service
|
|
||||||
|
|
||||||
import (
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
|
|
||||||
)
|
|
||||||
|
|
||||||
// probe is one observatory sample: whether the outbound is alive and the
|
|
||||||
// last_try_time xray reports for it (a new probe advances lastTry).
|
|
||||||
type probe struct {
|
|
||||||
alive bool
|
|
||||||
lastTry int64
|
|
||||||
}
|
|
||||||
|
|
||||||
const testSentinel eventbus.EventType = "test.sentinel"
|
|
||||||
|
|
||||||
// runObservatory feeds a probe sequence through applyObservatory with the given
|
|
||||||
// threshold and returns the outbound.* events it published, in order.
|
|
||||||
func runObservatory(t *testing.T, threshold int, seq []probe) []eventbus.EventType {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ss := SettingService{}
|
|
||||||
if err := ss.SetOutboundDownThreshold(threshold); err != nil {
|
|
||||||
t.Fatalf("set threshold: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
bus := eventbus.New(256)
|
|
||||||
events := make(chan eventbus.Event, 256)
|
|
||||||
bus.Subscribe("test", func(e eventbus.Event) { events <- e })
|
|
||||||
SetEventBus(bus)
|
|
||||||
t.Cleanup(func() {
|
|
||||||
SetEventBus(nil)
|
|
||||||
bus.Stop()
|
|
||||||
})
|
|
||||||
|
|
||||||
s := &XrayMetricsService{settingService: ss}
|
|
||||||
for _, p := range seq {
|
|
||||||
s.applyObservatory(time.Unix(p.lastTry, 0), map[string]rawObsEntry{
|
|
||||||
"proxy": {Alive: p.alive, Delay: 10, LastTryTime: p.lastTry, OutboundTag: "proxy"},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
bus.Publish(eventbus.Event{Type: testSentinel, Source: "x"})
|
|
||||||
var got []eventbus.EventType
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case e := <-events:
|
|
||||||
if e.Type == testSentinel {
|
|
||||||
return got
|
|
||||||
}
|
|
||||||
got = append(got, e.Type)
|
|
||||||
case <-time.After(2 * time.Second):
|
|
||||||
t.Fatal("timed out waiting for events to drain")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyObservatoryDebounce(t *testing.T) {
|
|
||||||
if err := database.InitDB(filepath.Join(t.TempDir(), "x-ui.db")); err != nil {
|
|
||||||
t.Fatalf("init db: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = database.CloseDB() })
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
threshold int
|
|
||||||
seq []probe
|
|
||||||
want []eventbus.EventType
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "notifies only after threshold consecutive failed probes",
|
|
||||||
threshold: 3,
|
|
||||||
seq: []probe{
|
|
||||||
{true, 1},
|
|
||||||
{false, 2},
|
|
||||||
{false, 3},
|
|
||||||
{false, 4},
|
|
||||||
{false, 5},
|
|
||||||
{true, 6},
|
|
||||||
{false, 7},
|
|
||||||
{true, 8},
|
|
||||||
},
|
|
||||||
want: []eventbus.EventType{eventbus.EventOutboundDown, eventbus.EventOutboundUp},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "repeated samples of the same probe do not advance the streak",
|
|
||||||
threshold: 3,
|
|
||||||
seq: []probe{{false, 2}, {false, 2}, {false, 2}, {false, 2}, {false, 2}},
|
|
||||||
want: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "single-probe blip never notifies",
|
|
||||||
threshold: 3,
|
|
||||||
seq: []probe{{true, 1}, {false, 2}, {true, 3}},
|
|
||||||
want: nil,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "threshold 1 keeps the legacy notify-on-first-failure behaviour",
|
|
||||||
threshold: 1,
|
|
||||||
seq: []probe{{true, 1}, {false, 2}},
|
|
||||||
want: []eventbus.EventType{eventbus.EventOutboundDown},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got := runObservatory(t, tt.threshold, tt.seq)
|
|
||||||
if len(got) != len(tt.want) {
|
|
||||||
t.Fatalf("events = %v, want %v", got, tt.want)
|
|
||||||
}
|
|
||||||
for i := range got {
|
|
||||||
if got[i] != tt.want[i] {
|
|
||||||
t.Fatalf("event[%d] = %q, want %q (full: %v)", i, got[i], tt.want[i], got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestValidObsTag(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
tag string
|
|
||||||
want bool
|
|
||||||
}{
|
|
||||||
{"plain ascii", "proxy-1", true},
|
|
||||||
{"dots and underscores", "warp_us.east", true},
|
|
||||||
{"flag emoji", "🇩🇪 Germany", true},
|
|
||||||
{"cyrillic", "Германия", true},
|
|
||||||
{"spaces allowed", "US proxy 2", true},
|
|
||||||
{"empty rejected", "", false},
|
|
||||||
{"control char rejected", "bad\x00tag", false},
|
|
||||||
{"newline rejected", "bad\ntag", false},
|
|
||||||
{"invalid utf8 rejected", string([]byte{0xff, 0xfe}), false},
|
|
||||||
{"overlong rejected", strings.Repeat("a", maxObsTagLength+1), false},
|
|
||||||
}
|
|
||||||
for _, tc := range tests {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
if got := validObsTag(tc.tag); got != tc.want {
|
|
||||||
t.Fatalf("validObsTag(%q) = %v, want %v", tc.tag, got, tc.want)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestApplyObservatoryKeepsUnicodeTags(t *testing.T) {
|
|
||||||
dbDir := t.TempDir()
|
|
||||||
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
|
||||||
t.Fatalf("InitDB: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { _ = database.CloseDB() })
|
|
||||||
|
|
||||||
s := &XrayMetricsService{settingService: SettingService{}}
|
|
||||||
s.applyObservatory(time.Unix(1000, 0), map[string]rawObsEntry{
|
|
||||||
"🇩🇪 Berlin": {Alive: true, Delay: 42, LastTryTime: 1},
|
|
||||||
})
|
|
||||||
|
|
||||||
if !s.HasObservatoryTag("🇩🇪 Berlin") {
|
|
||||||
t.Fatal("emoji-tagged outbound must appear in the observatory")
|
|
||||||
}
|
|
||||||
snaps := s.ObservatorySnapshot()
|
|
||||||
if len(snaps) != 1 || snaps[0].Tag != "🇩🇪 Berlin" {
|
|
||||||
t.Fatalf("snapshot = %+v, want the emoji tag", snaps)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -136,45 +136,6 @@
|
|||||||
"currentCommit": "الكومِت الحالي",
|
"currentCommit": "الكومِت الحالي",
|
||||||
"latestCommit": "أحدث كومِت",
|
"latestCommit": "أحدث كومِت",
|
||||||
"updateChannelChanged": "تم تغيير قناة التحديث",
|
"updateChannelChanged": "تم تغيير قناة التحديث",
|
||||||
"systemHistoryTitle": "تاريخ النظام",
|
|
||||||
"historyTitleCpu": "استخدام المعالج",
|
|
||||||
"historyTitleMem": "استخدام الذاكرة",
|
|
||||||
"historyTitleNetwork": "عرض النطاق الترددي للشبكة",
|
|
||||||
"historyTitlePackets": "حزم الشبكة",
|
|
||||||
"historyTitleDisk": "إدخال/إخراج القرص",
|
|
||||||
"historyTitleOnline": "العملاء المتصلون",
|
|
||||||
"historyTitleLoad": "متوسط حمل النظام (1 / 5 / 15 دقيقة)",
|
|
||||||
"historyTitleConnections": "الاتصالات النشطة (TCP / UDP)",
|
|
||||||
"historyTitleDiskUsage": "استخدام مساحة القرص",
|
|
||||||
"historyTabBandwidth": "عرض النطاق",
|
|
||||||
"historyTabPackets": "الحزم",
|
|
||||||
"historyTabDisk": "قرص I/O",
|
|
||||||
"historyTabOnline": "متصل",
|
|
||||||
"historyTabLoad": "الحِمل",
|
|
||||||
"historyTabConnections": "الاتصالات",
|
|
||||||
"historyTabDiskUsage": "استخدام القرص",
|
|
||||||
"xrayMetricsTitle": "مقاييس Xray",
|
|
||||||
"xrayTitleHeap": "ذاكرة الكومة المخصصة",
|
|
||||||
"xrayTitleSys": "الذاكرة المحجوزة من نظام التشغيل",
|
|
||||||
"xrayTitleObjects": "كائنات الكومة النشطة",
|
|
||||||
"xrayTitleGcCount": "دورات GC المكتملة",
|
|
||||||
"xrayTitleGcPause": "مدة توقف GC",
|
|
||||||
"xrayTitleObservatory": "صحة الاتصال الصادر",
|
|
||||||
"xrayTabHeap": "Heap",
|
|
||||||
"xrayTabSys": "Sys",
|
|
||||||
"xrayTabObjects": "الكائنات",
|
|
||||||
"xrayTabGcCount": "عدد GC",
|
|
||||||
"xrayTabGcPause": "توقف GC",
|
|
||||||
"xrayTabObservatory": "المرصد",
|
|
||||||
"xrayMetricsDisabled": "نقطة نهاية مقاييس Xray غير مهيأة",
|
|
||||||
"xrayMetricsHint": "أضف كتلة metrics على المستوى الأعلى في إعدادات xray مع tag باسم metrics_out و listen على 127.0.0.1:11111، ثم أعد تشغيل xray.",
|
|
||||||
"xrayObservatoryEmpty": "لا توجد بيانات Observatory بعد",
|
|
||||||
"xrayObservatoryHint": "أضف كتلة observatory إلى إعدادات xray مع قائمة وسوم outbound للفحص، ثم أعد تشغيل xray.",
|
|
||||||
"xrayObservatoryTagPlaceholder": "اختر outbound",
|
|
||||||
"xrayObservatoryAlive": "نشط",
|
|
||||||
"xrayObservatoryDead": "غير متصل",
|
|
||||||
"xrayObservatoryLastSeen": "آخر مشاهدة",
|
|
||||||
"xrayObservatoryLastTry": "آخر محاولة",
|
|
||||||
"connectionCount": "إحصائيات الاتصال",
|
"connectionCount": "إحصائيات الاتصال",
|
||||||
"ipAddresses": "عناوين IP",
|
"ipAddresses": "عناوين IP",
|
||||||
"toggleIpVisibility": "بدل إظهار IP",
|
"toggleIpVisibility": "بدل إظهار IP",
|
||||||
@@ -191,18 +152,6 @@
|
|||||||
"panelUpdateUnknownTitle": "تعذّر التأكد من اكتمال التحديث",
|
"panelUpdateUnknownTitle": "تعذّر التأكد من اكتمال التحديث",
|
||||||
"panelUpdateUnknownDesc": "لم تُبلغ اللوحة بنتيجة في الوقت المحدد. أعد تحميل الصفحة للتحقق من الإصدار الحالي، أو تحقق من سجلات الخادم.",
|
"panelUpdateUnknownDesc": "لم تُبلغ اللوحة بنتيجة في الوقت المحدد. أعد تحميل الصفحة للتحقق من الإصدار الحالي، أو تحقق من سجلات الخادم.",
|
||||||
"dontRefresh": "التثبيت شغال، متعملش Refresh للصفحة",
|
"dontRefresh": "التثبيت شغال، متعملش Refresh للصفحة",
|
||||||
"logs": "السجلات",
|
|
||||||
"accessLogs": "سجلات الوصول",
|
|
||||||
"autoUpdate": "تحديث تلقائي",
|
|
||||||
"amneziawgLogs": "سجلات AmneziaWG",
|
|
||||||
"amneziawgHandshake": "آخر مصافحة",
|
|
||||||
"amneziawgInterface": "الواجهة",
|
|
||||||
"amneziawgInbound": "الوارد",
|
|
||||||
"amneziawgEndpoint": "نقطة الاتصال",
|
|
||||||
"amneziawgIdle": "خامل",
|
|
||||||
"amneziawgEvents": "الأحداث",
|
|
||||||
"amneziawgNoPeers": "لا يوجد أقران AmneziaWG نشطون حاليًا",
|
|
||||||
"amneziawgNoEvents": "لم تُسجَّل أحداث AmneziaWG بعد",
|
|
||||||
"config": "الإعدادات",
|
"config": "الإعدادات",
|
||||||
"backupTitle": "نسخ احتياطي واستعادة",
|
"backupTitle": "نسخ احتياطي واستعادة",
|
||||||
"exportDatabase": "اخزن نسخة",
|
"exportDatabase": "اخزن نسخة",
|
||||||
@@ -219,25 +168,14 @@
|
|||||||
"importDatabasePgDesc": "انقر لاختيار ورفع نسخة احتياطية من PostgreSQL (.dump) أو قاعدة بيانات SQLite (.db) أو ملف ترحيل SQLite لاستعادة قاعدة البيانات. سيؤدي هذا إلى استبدال جميع البيانات الحالية.",
|
"importDatabasePgDesc": "انقر لاختيار ورفع نسخة احتياطية من PostgreSQL (.dump) أو قاعدة بيانات SQLite (.db) أو ملف ترحيل SQLite لاستعادة قاعدة البيانات. سيؤدي هذا إلى استبدال جميع البيانات الحالية.",
|
||||||
"migrationDownload": "تنزيل ملف الترحيل",
|
"migrationDownload": "تنزيل ملف الترحيل",
|
||||||
"migrationDownloadPgDesc": "انقر لتنزيل قاعدة بيانات SQLite بامتداد .db مبنية من بيانات PostgreSQL الخاصة بك، جاهزة لتشغيل هذه اللوحة على SQLite.",
|
"migrationDownloadPgDesc": "انقر لتنزيل قاعدة بيانات SQLite بامتداد .db مبنية من بيانات PostgreSQL الخاصة بك، جاهزة لتشغيل هذه اللوحة على SQLite.",
|
||||||
"avg": "متوسط",
|
|
||||||
"peak": "ذروة",
|
|
||||||
"free": "متاح",
|
"free": "متاح",
|
||||||
"openSockets": "مقابس مفتوحة",
|
"openSockets": "مقابس مفتوحة",
|
||||||
"throughputSub": "إجمالي الواجهة",
|
"throughputSub": "إجمالي الواجهة",
|
||||||
"avgWindow": "متوسط الفترة",
|
|
||||||
"healthWarm": "{list} — مرتفع قليلاً",
|
"healthWarm": "{list} — مرتفع قليلاً",
|
||||||
"healthCritical": "{list} — حرج",
|
"healthCritical": "{list} — حرج",
|
||||||
"panel": "اللوحة",
|
"panel": "اللوحة",
|
||||||
"threads": "الخيوط",
|
"threads": "الخيوط",
|
||||||
"uptime": "مدة التشغيل",
|
"uptime": "مدة التشغيل",
|
||||||
"logLevelDebug": "Debug",
|
|
||||||
"logLevelInfo": "Info",
|
|
||||||
"logLevelNotice": "Notice",
|
|
||||||
"logLevelWarning": "Warning",
|
|
||||||
"logLevelError": "Error",
|
|
||||||
"accessDirect": "DIRECT",
|
|
||||||
"accessBlocked": "BLOCKED",
|
|
||||||
"accessProxy": "PROXY",
|
|
||||||
"importKeepHostSettings": "الاحتفاظ بإعدادات هذا الجهاز",
|
"importKeepHostSettings": "الاحتفاظ بإعدادات هذا الجهاز",
|
||||||
"importKeepHostSettingsDesc": "يحتفظ بعناوين الاستماع والمنافذ والمسار الأساسي والشهادات وهوية العقدة الخاصة بهذه اللوحة بدلًا من أخذها من الملف المرفوع."
|
"importKeepHostSettingsDesc": "يحتفظ بعناوين الاستماع والمنافذ والمسار الأساسي والشهادات وهوية العقدة الخاصة بهذه اللوحة بدلًا من أخذها من الملف المرفوع."
|
||||||
},
|
},
|
||||||
@@ -970,7 +908,19 @@
|
|||||||
"pinnedCertPlaceholder": "SHA-256 بصيغة base64 أو hex",
|
"pinnedCertPlaceholder": "SHA-256 بصيغة base64 أو hex",
|
||||||
"fetchPin": "جلب",
|
"fetchPin": "جلب",
|
||||||
"pinFetched": "تم جلب شهادة العقدة الحالية",
|
"pinFetched": "تم جلب شهادة العقدة الحالية",
|
||||||
"pinFetchFailed": "تعذّر جلب الشهادة"
|
"pinFetchFailed": "تعذّر جلب الشهادة",
|
||||||
|
"nodeType": "Node Type",
|
||||||
|
"nodeTypeTraditional": "Traditional Panel Node",
|
||||||
|
"nodeTypeAgent": "Agent Lightweight Node",
|
||||||
|
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||||
|
"agentTokenTitle": "Agent Node Setup",
|
||||||
|
"agentTokenCreated": "Node {{name}} created successfully",
|
||||||
|
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||||
|
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
|
||||||
|
"agentTokenCopied": "Install command copied",
|
||||||
|
"agentTokenCopyFailed": "Copy failed, please copy manually",
|
||||||
|
"copyToken": "Copy Install Command",
|
||||||
|
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "افتراضي",
|
"defaultTag": "افتراضي",
|
||||||
@@ -1725,4 +1675,4 @@
|
|||||||
"noIpRecord": "❗ لا يوجد سجل IP!"
|
"noIpRecord": "❗ لا يوجد سجل IP!"
|
||||||
},
|
},
|
||||||
"email": {}
|
"email": {}
|
||||||
}
|
}
|
||||||
@@ -136,45 +136,6 @@
|
|||||||
"currentCommit": "Current commit",
|
"currentCommit": "Current commit",
|
||||||
"latestCommit": "Latest commit",
|
"latestCommit": "Latest commit",
|
||||||
"updateChannelChanged": "Update channel changed",
|
"updateChannelChanged": "Update channel changed",
|
||||||
"systemHistoryTitle": "System History",
|
|
||||||
"historyTitleCpu": "CPU Usage",
|
|
||||||
"historyTitleMem": "Memory Usage",
|
|
||||||
"historyTitleNetwork": "Network Bandwidth",
|
|
||||||
"historyTitlePackets": "Network Packets",
|
|
||||||
"historyTitleDisk": "Disk I/O",
|
|
||||||
"historyTitleOnline": "Online Clients",
|
|
||||||
"historyTitleLoad": "System Load Average (1m / 5m / 15m)",
|
|
||||||
"historyTitleConnections": "Active Connections (TCP / UDP)",
|
|
||||||
"historyTitleDiskUsage": "Disk Space Usage",
|
|
||||||
"historyTabBandwidth": "Bandwidth",
|
|
||||||
"historyTabPackets": "Packets",
|
|
||||||
"historyTabDisk": "Disk I/O",
|
|
||||||
"historyTabOnline": "Online",
|
|
||||||
"historyTabLoad": "Load",
|
|
||||||
"historyTabConnections": "Connections",
|
|
||||||
"historyTabDiskUsage": "Disk Usage",
|
|
||||||
"xrayMetricsTitle": "Xray Metrics",
|
|
||||||
"xrayTitleHeap": "Allocated Heap Memory",
|
|
||||||
"xrayTitleSys": "Memory Reserved from OS",
|
|
||||||
"xrayTitleObjects": "Live Heap Objects",
|
|
||||||
"xrayTitleGcCount": "Completed GC Cycles",
|
|
||||||
"xrayTitleGcPause": "GC Pause Duration",
|
|
||||||
"xrayTitleObservatory": "Outbound Connection Health",
|
|
||||||
"xrayTabHeap": "Heap",
|
|
||||||
"xrayTabSys": "Sys",
|
|
||||||
"xrayTabObjects": "Objects",
|
|
||||||
"xrayTabGcCount": "GC Count",
|
|
||||||
"xrayTabGcPause": "GC Pause",
|
|
||||||
"xrayTabObservatory": "Observatory",
|
|
||||||
"xrayMetricsDisabled": "Xray metrics endpoint not configured",
|
|
||||||
"xrayMetricsHint": "Add a top-level metrics block to the xray config with tag metrics_out and listen 127.0.0.1:11111, then restart xray.",
|
|
||||||
"xrayObservatoryEmpty": "No observatory data yet",
|
|
||||||
"xrayObservatoryHint": "Add an observatory block to the xray config listing the outbound tags to probe, then restart xray.",
|
|
||||||
"xrayObservatoryTagPlaceholder": "Select outbound",
|
|
||||||
"xrayObservatoryAlive": "Alive",
|
|
||||||
"xrayObservatoryDead": "Down",
|
|
||||||
"xrayObservatoryLastSeen": "Last seen",
|
|
||||||
"xrayObservatoryLastTry": "Last try",
|
|
||||||
"connectionCount": "Connection Stats",
|
"connectionCount": "Connection Stats",
|
||||||
"ipAddresses": "IP Addresses",
|
"ipAddresses": "IP Addresses",
|
||||||
"toggleIpVisibility": "Toggle visibility of the IP",
|
"toggleIpVisibility": "Toggle visibility of the IP",
|
||||||
@@ -191,18 +152,6 @@
|
|||||||
"panelUpdateUnknownTitle": "Couldn't confirm the update finished",
|
"panelUpdateUnknownTitle": "Couldn't confirm the update finished",
|
||||||
"panelUpdateUnknownDesc": "The panel didn't report a result in time. Reload to check the current version, or check the server logs.",
|
"panelUpdateUnknownDesc": "The panel didn't report a result in time. Reload to check the current version, or check the server logs.",
|
||||||
"dontRefresh": "Installation is in progress, please do not refresh this page",
|
"dontRefresh": "Installation is in progress, please do not refresh this page",
|
||||||
"logs": "Logs",
|
|
||||||
"accessLogs": "Access Logs",
|
|
||||||
"autoUpdate": "Auto Update",
|
|
||||||
"amneziawgLogs": "AmneziaWG Logs",
|
|
||||||
"amneziawgHandshake": "Last Handshake",
|
|
||||||
"amneziawgInterface": "Interface",
|
|
||||||
"amneziawgInbound": "Inbound",
|
|
||||||
"amneziawgEndpoint": "Endpoint",
|
|
||||||
"amneziawgIdle": "Idle",
|
|
||||||
"amneziawgEvents": "Events",
|
|
||||||
"amneziawgNoPeers": "No AmneziaWG peers are currently up",
|
|
||||||
"amneziawgNoEvents": "No AmneziaWG events recorded yet",
|
|
||||||
"config": "Config",
|
"config": "Config",
|
||||||
"backupTitle": "Backup & Restore",
|
"backupTitle": "Backup & Restore",
|
||||||
"exportDatabase": "Back Up",
|
"exportDatabase": "Back Up",
|
||||||
@@ -219,25 +168,14 @@
|
|||||||
"importDatabasePgDesc": "Click to select and upload a PostgreSQL backup (.dump), a SQLite database (.db), or a SQLite migration dump to restore your database. This replaces all current data.",
|
"importDatabasePgDesc": "Click to select and upload a PostgreSQL backup (.dump), a SQLite database (.db), or a SQLite migration dump to restore your database. This replaces all current data.",
|
||||||
"migrationDownload": "Download Migration",
|
"migrationDownload": "Download Migration",
|
||||||
"migrationDownloadPgDesc": "Click to download a .db SQLite database built from your PostgreSQL data, ready to run this panel on SQLite.",
|
"migrationDownloadPgDesc": "Click to download a .db SQLite database built from your PostgreSQL data, ready to run this panel on SQLite.",
|
||||||
"avg": "avg",
|
|
||||||
"peak": "peak",
|
|
||||||
"free": "free",
|
"free": "free",
|
||||||
"openSockets": "open sockets",
|
"openSockets": "open sockets",
|
||||||
"throughputSub": "Interface total",
|
"throughputSub": "Interface total",
|
||||||
"avgWindow": "Avg over window",
|
|
||||||
"healthWarm": "{list} — running warm",
|
"healthWarm": "{list} — running warm",
|
||||||
"healthCritical": "{list} — critical",
|
"healthCritical": "{list} — critical",
|
||||||
"panel": "Panel",
|
"panel": "Panel",
|
||||||
"threads": "Threads",
|
"threads": "Threads",
|
||||||
"uptime": "Uptime",
|
"uptime": "Uptime",
|
||||||
"logLevelDebug": "Debug",
|
|
||||||
"logLevelInfo": "Info",
|
|
||||||
"logLevelNotice": "Notice",
|
|
||||||
"logLevelWarning": "Warning",
|
|
||||||
"logLevelError": "Error",
|
|
||||||
"accessDirect": "DIRECT",
|
|
||||||
"accessBlocked": "BLOCKED",
|
|
||||||
"accessProxy": "PROXY",
|
|
||||||
"importKeepHostSettings": "Keep this machine's settings",
|
"importKeepHostSettings": "Keep this machine's settings",
|
||||||
"importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
|
"importKeepHostSettingsDesc": "Keeps this panel's listen addresses, ports, base path, certificates and node identity instead of taking them from the uploaded file."
|
||||||
},
|
},
|
||||||
@@ -1088,7 +1026,19 @@
|
|||||||
"pinnedCertPlaceholder": "base64 or hex SHA-256",
|
"pinnedCertPlaceholder": "base64 or hex SHA-256",
|
||||||
"fetchPin": "Fetch",
|
"fetchPin": "Fetch",
|
||||||
"pinFetched": "Fetched the node's current certificate",
|
"pinFetched": "Fetched the node's current certificate",
|
||||||
"pinFetchFailed": "Could not fetch the certificate"
|
"pinFetchFailed": "Could not fetch the certificate",
|
||||||
|
"nodeType": "Node Type",
|
||||||
|
"nodeTypeTraditional": "Traditional Panel Node",
|
||||||
|
"nodeTypeAgent": "Agent Lightweight Node",
|
||||||
|
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||||
|
"agentTokenTitle": "Agent Node Setup",
|
||||||
|
"agentTokenCreated": "Node {{name}} created successfully",
|
||||||
|
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||||
|
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
|
||||||
|
"agentTokenCopied": "Install command copied",
|
||||||
|
"agentTokenCopyFailed": "Copy failed, please copy manually",
|
||||||
|
"copyToken": "Copy Install Command",
|
||||||
|
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "Default",
|
"defaultTag": "Default",
|
||||||
@@ -1192,7 +1142,7 @@
|
|||||||
"subListen": "Listen IP",
|
"subListen": "Listen IP",
|
||||||
"subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)",
|
"subListenDesc": "The IP address for the subscription service. (leave blank to listen on all IPs)",
|
||||||
"subPort": "Listen Port",
|
"subPort": "Listen Port",
|
||||||
"subPortDesc": "The port number for the subscription service. (must be an unused port). Also used to build the subscription link/QR shown in the panel when \"Reverse Proxy URI\" below is empty — if the subscription is reached through a reverse proxy on a different port, set \"Reverse Proxy URI\" instead.",
|
"subPortDesc": "The port number for the subscription service. (must be an unused port). Also used to build the subscription link/QR shown in the panel when \"Reverse Proxy URI\" below is empty — if the subscription is reached through a reverse proxy on a different port, set \"Reverse Proxy URI\" instead.Leave empty to omit the port from subscription links (the service still listens on the default port 2096).",
|
||||||
"subCertPath": "Public Key Path",
|
"subCertPath": "Public Key Path",
|
||||||
"subCertPathDesc": "The public key file path for the subscription service. (begins with ‘/‘)",
|
"subCertPathDesc": "The public key file path for the subscription service. (begins with ‘/‘)",
|
||||||
"subKeyPath": "Private Key Path",
|
"subKeyPath": "Private Key Path",
|
||||||
@@ -1725,4 +1675,4 @@
|
|||||||
"noIpRecord": "❗ No IP Record!"
|
"noIpRecord": "❗ No IP Record!"
|
||||||
},
|
},
|
||||||
"email": {}
|
"email": {}
|
||||||
}
|
}
|
||||||
@@ -136,45 +136,6 @@
|
|||||||
"currentCommit": "Commit actual",
|
"currentCommit": "Commit actual",
|
||||||
"latestCommit": "Último commit",
|
"latestCommit": "Último commit",
|
||||||
"updateChannelChanged": "Canal de actualización cambiado",
|
"updateChannelChanged": "Canal de actualización cambiado",
|
||||||
"systemHistoryTitle": "Historial del Sistema",
|
|
||||||
"historyTitleCpu": "Uso de CPU",
|
|
||||||
"historyTitleMem": "Uso de Memoria",
|
|
||||||
"historyTitleNetwork": "Ancho de Banda de Red",
|
|
||||||
"historyTitlePackets": "Paquetes de Red",
|
|
||||||
"historyTitleDisk": "E/S de Disco",
|
|
||||||
"historyTitleOnline": "Clientes en Línea",
|
|
||||||
"historyTitleLoad": "Carga Media del Sistema (1 / 5 / 15 min)",
|
|
||||||
"historyTitleConnections": "Conexiones Activas (TCP / UDP)",
|
|
||||||
"historyTitleDiskUsage": "Uso del Espacio en Disco",
|
|
||||||
"historyTabBandwidth": "Ancho de Banda",
|
|
||||||
"historyTabPackets": "Paquetes",
|
|
||||||
"historyTabDisk": "Disco I/O",
|
|
||||||
"historyTabOnline": "En línea",
|
|
||||||
"historyTabLoad": "Carga",
|
|
||||||
"historyTabConnections": "Conexiones",
|
|
||||||
"historyTabDiskUsage": "Uso de Disco",
|
|
||||||
"xrayMetricsTitle": "Métricas de Xray",
|
|
||||||
"xrayTitleHeap": "Memoria Heap Asignada",
|
|
||||||
"xrayTitleSys": "Memoria Reservada del SO",
|
|
||||||
"xrayTitleObjects": "Objetos Heap Activos",
|
|
||||||
"xrayTitleGcCount": "Ciclos de GC Completados",
|
|
||||||
"xrayTitleGcPause": "Duración de Pausa de GC",
|
|
||||||
"xrayTitleObservatory": "Estado de Conexiones Salientes",
|
|
||||||
"xrayTabHeap": "Heap",
|
|
||||||
"xrayTabSys": "Sys",
|
|
||||||
"xrayTabObjects": "Objetos",
|
|
||||||
"xrayTabGcCount": "Recuento GC",
|
|
||||||
"xrayTabGcPause": "Pausa GC",
|
|
||||||
"xrayTabObservatory": "Observatorio",
|
|
||||||
"xrayMetricsDisabled": "Endpoint de métricas de Xray no configurado",
|
|
||||||
"xrayMetricsHint": "Añade un bloque metrics de nivel superior a la configuración de xray con tag metrics_out y listen 127.0.0.1:11111, luego reinicia xray.",
|
|
||||||
"xrayObservatoryEmpty": "Aún no hay datos de Observatory",
|
|
||||||
"xrayObservatoryHint": "Añade un bloque observatory a la configuración de xray listando los tags de outbound a sondear, luego reinicia xray.",
|
|
||||||
"xrayObservatoryTagPlaceholder": "Seleccionar outbound",
|
|
||||||
"xrayObservatoryAlive": "Activo",
|
|
||||||
"xrayObservatoryDead": "Caído",
|
|
||||||
"xrayObservatoryLastSeen": "Visto por última vez",
|
|
||||||
"xrayObservatoryLastTry": "Último intento",
|
|
||||||
"connectionCount": "Número de Conexiones",
|
"connectionCount": "Número de Conexiones",
|
||||||
"ipAddresses": "Direcciones IP",
|
"ipAddresses": "Direcciones IP",
|
||||||
"toggleIpVisibility": "Alternar visibilidad de la IP",
|
"toggleIpVisibility": "Alternar visibilidad de la IP",
|
||||||
@@ -191,18 +152,6 @@
|
|||||||
"panelUpdateUnknownTitle": "No se pudo confirmar si la actualización terminó",
|
"panelUpdateUnknownTitle": "No se pudo confirmar si la actualización terminó",
|
||||||
"panelUpdateUnknownDesc": "El panel no informó un resultado a tiempo. Recarga la página para comprobar la versión actual, o revisa los registros del servidor.",
|
"panelUpdateUnknownDesc": "El panel no informó un resultado a tiempo. Recarga la página para comprobar la versión actual, o revisa los registros del servidor.",
|
||||||
"dontRefresh": "La instalación está en progreso, por favor no actualices esta página.",
|
"dontRefresh": "La instalación está en progreso, por favor no actualices esta página.",
|
||||||
"logs": "Registros",
|
|
||||||
"accessLogs": "Registros de acceso",
|
|
||||||
"autoUpdate": "Actualización automática",
|
|
||||||
"amneziawgLogs": "Registros de AmneziaWG",
|
|
||||||
"amneziawgHandshake": "Último handshake",
|
|
||||||
"amneziawgInterface": "Interfaz",
|
|
||||||
"amneziawgInbound": "Entrada",
|
|
||||||
"amneziawgEndpoint": "Endpoint",
|
|
||||||
"amneziawgIdle": "Inactivo",
|
|
||||||
"amneziawgEvents": "Eventos",
|
|
||||||
"amneziawgNoPeers": "No hay peers de AmneziaWG activos",
|
|
||||||
"amneziawgNoEvents": "Aún no hay eventos de AmneziaWG registrados",
|
|
||||||
"config": "Configuración",
|
"config": "Configuración",
|
||||||
"backupTitle": "Copia & Restauración",
|
"backupTitle": "Copia & Restauración",
|
||||||
"exportDatabase": "Copia de seguridad",
|
"exportDatabase": "Copia de seguridad",
|
||||||
@@ -219,25 +168,14 @@
|
|||||||
"importDatabasePgDesc": "Haz clic para seleccionar y subir una copia de seguridad de PostgreSQL (.dump), una base de datos SQLite (.db) o un volcado de migración de SQLite para restaurar tu base de datos. Esto reemplaza todos los datos actuales.",
|
"importDatabasePgDesc": "Haz clic para seleccionar y subir una copia de seguridad de PostgreSQL (.dump), una base de datos SQLite (.db) o un volcado de migración de SQLite para restaurar tu base de datos. Esto reemplaza todos los datos actuales.",
|
||||||
"migrationDownload": "Descargar migración",
|
"migrationDownload": "Descargar migración",
|
||||||
"migrationDownloadPgDesc": "Haz clic para descargar una base de datos SQLite .db creada a partir de tus datos de PostgreSQL, lista para ejecutar este panel en SQLite.",
|
"migrationDownloadPgDesc": "Haz clic para descargar una base de datos SQLite .db creada a partir de tus datos de PostgreSQL, lista para ejecutar este panel en SQLite.",
|
||||||
"avg": "media",
|
|
||||||
"peak": "pico",
|
|
||||||
"free": "libre",
|
"free": "libre",
|
||||||
"openSockets": "sockets abiertos",
|
"openSockets": "sockets abiertos",
|
||||||
"throughputSub": "Total de la interfaz",
|
"throughputSub": "Total de la interfaz",
|
||||||
"avgWindow": "Media del periodo",
|
|
||||||
"healthWarm": "{list} — algo elevado",
|
"healthWarm": "{list} — algo elevado",
|
||||||
"healthCritical": "{list} — crítico",
|
"healthCritical": "{list} — crítico",
|
||||||
"panel": "Panel",
|
"panel": "Panel",
|
||||||
"threads": "Hilos",
|
"threads": "Hilos",
|
||||||
"uptime": "Tiempo activo",
|
"uptime": "Tiempo activo",
|
||||||
"logLevelDebug": "Debug",
|
|
||||||
"logLevelInfo": "Info",
|
|
||||||
"logLevelNotice": "Notice",
|
|
||||||
"logLevelWarning": "Warning",
|
|
||||||
"logLevelError": "Error",
|
|
||||||
"accessDirect": "DIRECT",
|
|
||||||
"accessBlocked": "BLOCKED",
|
|
||||||
"accessProxy": "PROXY",
|
|
||||||
"importKeepHostSettings": "Mantener la configuración de esta máquina",
|
"importKeepHostSettings": "Mantener la configuración de esta máquina",
|
||||||
"importKeepHostSettingsDesc": "Conserva las direcciones de escucha, los puertos, la ruta base, los certificados y la identidad de nodo de este panel en lugar de tomarlos del archivo subido."
|
"importKeepHostSettingsDesc": "Conserva las direcciones de escucha, los puertos, la ruta base, los certificados y la identidad de nodo de este panel en lugar de tomarlos del archivo subido."
|
||||||
},
|
},
|
||||||
@@ -970,7 +908,19 @@
|
|||||||
"pinnedCertPlaceholder": "SHA-256 en base64 o hex",
|
"pinnedCertPlaceholder": "SHA-256 en base64 o hex",
|
||||||
"fetchPin": "Obtener",
|
"fetchPin": "Obtener",
|
||||||
"pinFetched": "Se obtuvo el certificado actual del nodo",
|
"pinFetched": "Se obtuvo el certificado actual del nodo",
|
||||||
"pinFetchFailed": "No se pudo obtener el certificado"
|
"pinFetchFailed": "No se pudo obtener el certificado",
|
||||||
|
"nodeType": "Node Type",
|
||||||
|
"nodeTypeTraditional": "Traditional Panel Node",
|
||||||
|
"nodeTypeAgent": "Agent Lightweight Node",
|
||||||
|
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||||
|
"agentTokenTitle": "Agent Node Setup",
|
||||||
|
"agentTokenCreated": "Node {{name}} created successfully",
|
||||||
|
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||||
|
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
|
||||||
|
"agentTokenCopied": "Install command copied",
|
||||||
|
"agentTokenCopyFailed": "Copy failed, please copy manually",
|
||||||
|
"copyToken": "Copy Install Command",
|
||||||
|
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "Predeterminado",
|
"defaultTag": "Predeterminado",
|
||||||
@@ -1725,4 +1675,4 @@
|
|||||||
"noIpRecord": "❗ ¡No hay registro de IP!"
|
"noIpRecord": "❗ ¡No hay registro de IP!"
|
||||||
},
|
},
|
||||||
"email": {}
|
"email": {}
|
||||||
}
|
}
|
||||||
@@ -136,45 +136,6 @@
|
|||||||
"currentCommit": "کامیت فعلی",
|
"currentCommit": "کامیت فعلی",
|
||||||
"latestCommit": "آخرین کامیت",
|
"latestCommit": "آخرین کامیت",
|
||||||
"updateChannelChanged": "کانال بهروزرسانی تغییر کرد",
|
"updateChannelChanged": "کانال بهروزرسانی تغییر کرد",
|
||||||
"systemHistoryTitle": "تاریخچه سیستم",
|
|
||||||
"historyTitleCpu": "مصرف پردازنده",
|
|
||||||
"historyTitleMem": "مصرف حافظه",
|
|
||||||
"historyTitleNetwork": "پهنای باند شبکه",
|
|
||||||
"historyTitlePackets": "بستههای شبکه",
|
|
||||||
"historyTitleDisk": "ورودی/خروجی دیسک",
|
|
||||||
"historyTitleOnline": "کاربران آنلاین",
|
|
||||||
"historyTitleLoad": "میانگین بار سیستم (۱ / ۵ / ۱۵ دقیقه)",
|
|
||||||
"historyTitleConnections": "اتصالات فعال (TCP / UDP)",
|
|
||||||
"historyTitleDiskUsage": "مصرف فضای دیسک",
|
|
||||||
"historyTabBandwidth": "پهنای باند",
|
|
||||||
"historyTabPackets": "بستهها",
|
|
||||||
"historyTabDisk": "دیسک I/O",
|
|
||||||
"historyTabOnline": "آنلاین",
|
|
||||||
"historyTabLoad": "بار",
|
|
||||||
"historyTabConnections": "اتصالات",
|
|
||||||
"historyTabDiskUsage": "مصرف دیسک",
|
|
||||||
"xrayMetricsTitle": "متریکهای Xray",
|
|
||||||
"xrayTitleHeap": "حافظهی Heap تخصیصیافته",
|
|
||||||
"xrayTitleSys": "حافظهی رزروشده از سیستمعامل",
|
|
||||||
"xrayTitleObjects": "اشیای زندهی Heap",
|
|
||||||
"xrayTitleGcCount": "چرخههای کاملشدهی GC",
|
|
||||||
"xrayTitleGcPause": "مدت مکث GC",
|
|
||||||
"xrayTitleObservatory": "سلامت اتصال خروجی",
|
|
||||||
"xrayTabHeap": "Heap",
|
|
||||||
"xrayTabSys": "Sys",
|
|
||||||
"xrayTabObjects": "اشیا",
|
|
||||||
"xrayTabGcCount": "تعداد GC",
|
|
||||||
"xrayTabGcPause": "مکث GC",
|
|
||||||
"xrayTabObservatory": "رصدخانه",
|
|
||||||
"xrayMetricsDisabled": "نقطه پایانی متریکهای Xray پیکربندی نشده",
|
|
||||||
"xrayMetricsHint": "یک بلاک metrics در سطح بالای پیکربندی xray با tag برابر metrics_out و listen برابر 127.0.0.1:11111 اضافه کنید، سپس xray را راهاندازی مجدد کنید.",
|
|
||||||
"xrayObservatoryEmpty": "هنوز دادهای از Observatory دریافت نشده",
|
|
||||||
"xrayObservatoryHint": "یک بلاک observatory در پیکربندی xray اضافه کنید و outbound tagهایی که میخواهید بررسی شوند را لیست کنید، سپس xray را راهاندازی مجدد کنید.",
|
|
||||||
"xrayObservatoryTagPlaceholder": "انتخاب outbound",
|
|
||||||
"xrayObservatoryAlive": "فعال",
|
|
||||||
"xrayObservatoryDead": "غیرفعال",
|
|
||||||
"xrayObservatoryLastSeen": "آخرین مشاهده",
|
|
||||||
"xrayObservatoryLastTry": "آخرین تلاش",
|
|
||||||
"connectionCount": "تعداد کانکشن ها",
|
"connectionCount": "تعداد کانکشن ها",
|
||||||
"ipAddresses": "آدرسهای IP",
|
"ipAddresses": "آدرسهای IP",
|
||||||
"toggleIpVisibility": "تغییر وضعیت نمایش IP",
|
"toggleIpVisibility": "تغییر وضعیت نمایش IP",
|
||||||
@@ -191,18 +152,6 @@
|
|||||||
"panelUpdateUnknownTitle": "تأیید نشد که بهروزرسانی به پایان رسیده باشد",
|
"panelUpdateUnknownTitle": "تأیید نشد که بهروزرسانی به پایان رسیده باشد",
|
||||||
"panelUpdateUnknownDesc": "پنل بهموقع نتیجهای گزارش نکرد. صفحه را بارگذاری مجدد کنید تا نسخه فعلی را بررسی کنید یا گزارشهای سرور را بررسی کنید.",
|
"panelUpdateUnknownDesc": "پنل بهموقع نتیجهای گزارش نکرد. صفحه را بارگذاری مجدد کنید تا نسخه فعلی را بررسی کنید یا گزارشهای سرور را بررسی کنید.",
|
||||||
"dontRefresh": "در حال نصب، لطفا صفحه را رفرش نکنید",
|
"dontRefresh": "در حال نصب، لطفا صفحه را رفرش نکنید",
|
||||||
"logs": "لاگها",
|
|
||||||
"accessLogs": "لاگهای دسترسی",
|
|
||||||
"autoUpdate": "بهروزرسانی خودکار",
|
|
||||||
"amneziawgLogs": "لاگهای AmneziaWG",
|
|
||||||
"amneziawgHandshake": "آخرین دستدهی",
|
|
||||||
"amneziawgInterface": "رابط شبکه",
|
|
||||||
"amneziawgInbound": "ورودی",
|
|
||||||
"amneziawgEndpoint": "نقطه اتصال",
|
|
||||||
"amneziawgIdle": "بیکار",
|
|
||||||
"amneziawgEvents": "رویدادها",
|
|
||||||
"amneziawgNoPeers": "هیچ پیر فعالی برای AmneziaWG وجود ندارد",
|
|
||||||
"amneziawgNoEvents": "هنوز رویدادی برای AmneziaWG ثبت نشده است",
|
|
||||||
"config": "پیکربندی",
|
"config": "پیکربندی",
|
||||||
"backupTitle": "پشتیبانگیری و بازیابی",
|
"backupTitle": "پشتیبانگیری و بازیابی",
|
||||||
"exportDatabase": "پشتیبانگیری",
|
"exportDatabase": "پشتیبانگیری",
|
||||||
@@ -219,25 +168,14 @@
|
|||||||
"importDatabasePgDesc": "برای انتخاب و بارگذاری یک پشتیبان PostgreSQL (.dump)، پایگاهدادهٔ SQLite (.db) یا فایل مهاجرت SQLite جهت بازیابی پایگاه داده کلیک کنید. این کار همه دادههای فعلی را جایگزین میکند.",
|
"importDatabasePgDesc": "برای انتخاب و بارگذاری یک پشتیبان PostgreSQL (.dump)، پایگاهدادهٔ SQLite (.db) یا فایل مهاجرت SQLite جهت بازیابی پایگاه داده کلیک کنید. این کار همه دادههای فعلی را جایگزین میکند.",
|
||||||
"migrationDownload": "دانلود فایل مهاجرت",
|
"migrationDownload": "دانلود فایل مهاجرت",
|
||||||
"migrationDownloadPgDesc": "برای دانلود یک پایگاهدادهٔ SQLite با پسوند .db که از دادههای PostgreSQL شما ساخته میشود کلیک کنید؛ آمادهٔ اجرای این پنل روی SQLite.",
|
"migrationDownloadPgDesc": "برای دانلود یک پایگاهدادهٔ SQLite با پسوند .db که از دادههای PostgreSQL شما ساخته میشود کلیک کنید؛ آمادهٔ اجرای این پنل روی SQLite.",
|
||||||
"avg": "میانگین",
|
|
||||||
"peak": "اوج",
|
|
||||||
"free": "آزاد",
|
"free": "آزاد",
|
||||||
"openSockets": "اتصال باز",
|
"openSockets": "اتصال باز",
|
||||||
"throughputSub": "کل اینترفیس",
|
"throughputSub": "کل اینترفیس",
|
||||||
"avgWindow": "میانگین بازه",
|
|
||||||
"healthWarm": "{list} — رو به گرم شدن",
|
"healthWarm": "{list} — رو به گرم شدن",
|
||||||
"healthCritical": "{list} — بحرانی",
|
"healthCritical": "{list} — بحرانی",
|
||||||
"panel": "پنل",
|
"panel": "پنل",
|
||||||
"threads": "نخها",
|
"threads": "نخها",
|
||||||
"uptime": "مدت کارکرد",
|
"uptime": "مدت کارکرد",
|
||||||
"logLevelDebug": "Debug",
|
|
||||||
"logLevelInfo": "Info",
|
|
||||||
"logLevelNotice": "Notice",
|
|
||||||
"logLevelWarning": "Warning",
|
|
||||||
"logLevelError": "Error",
|
|
||||||
"accessDirect": "DIRECT",
|
|
||||||
"accessBlocked": "BLOCKED",
|
|
||||||
"accessProxy": "PROXY",
|
|
||||||
"importKeepHostSettings": "حفظ تنظیمات این دستگاه",
|
"importKeepHostSettings": "حفظ تنظیمات این دستگاه",
|
||||||
"importKeepHostSettingsDesc": "آدرسها و پورتهای شنود، مسیر پایه، گواهیها و هویت نودِ همین پنل را نگه میدارد و آنها را از فایل بارگذاریشده نمیگیرد."
|
"importKeepHostSettingsDesc": "آدرسها و پورتهای شنود، مسیر پایه، گواهیها و هویت نودِ همین پنل را نگه میدارد و آنها را از فایل بارگذاریشده نمیگیرد."
|
||||||
},
|
},
|
||||||
@@ -970,7 +908,19 @@
|
|||||||
"pinnedCertPlaceholder": "SHA-256 بهصورت base64 یا hex",
|
"pinnedCertPlaceholder": "SHA-256 بهصورت base64 یا hex",
|
||||||
"fetchPin": "دریافت",
|
"fetchPin": "دریافت",
|
||||||
"pinFetched": "گواهیِ فعلیِ نود دریافت شد",
|
"pinFetched": "گواهیِ فعلیِ نود دریافت شد",
|
||||||
"pinFetchFailed": "دریافت گواهی ممکن نشد"
|
"pinFetchFailed": "دریافت گواهی ممکن نشد",
|
||||||
|
"nodeType": "Node Type",
|
||||||
|
"nodeTypeTraditional": "Traditional Panel Node",
|
||||||
|
"nodeTypeAgent": "Agent Lightweight Node",
|
||||||
|
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||||
|
"agentTokenTitle": "Agent Node Setup",
|
||||||
|
"agentTokenCreated": "Node {{name}} created successfully",
|
||||||
|
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||||
|
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
|
||||||
|
"agentTokenCopied": "Install command copied",
|
||||||
|
"agentTokenCopyFailed": "Copy failed, please copy manually",
|
||||||
|
"copyToken": "Copy Install Command",
|
||||||
|
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "پیشفرض",
|
"defaultTag": "پیشفرض",
|
||||||
@@ -1725,4 +1675,4 @@
|
|||||||
"noIpRecord": "❗ رکورد آی پی وجود ندارد!"
|
"noIpRecord": "❗ رکورد آی پی وجود ندارد!"
|
||||||
},
|
},
|
||||||
"email": {}
|
"email": {}
|
||||||
}
|
}
|
||||||
@@ -136,45 +136,6 @@
|
|||||||
"currentCommit": "Commit saat ini",
|
"currentCommit": "Commit saat ini",
|
||||||
"latestCommit": "Commit terbaru",
|
"latestCommit": "Commit terbaru",
|
||||||
"updateChannelChanged": "Kanal pembaruan diubah",
|
"updateChannelChanged": "Kanal pembaruan diubah",
|
||||||
"systemHistoryTitle": "Riwayat Sistem",
|
|
||||||
"historyTitleCpu": "Penggunaan CPU",
|
|
||||||
"historyTitleMem": "Penggunaan Memori",
|
|
||||||
"historyTitleNetwork": "Bandwidth Jaringan",
|
|
||||||
"historyTitlePackets": "Paket Jaringan",
|
|
||||||
"historyTitleDisk": "I/O Disk",
|
|
||||||
"historyTitleOnline": "Klien Online",
|
|
||||||
"historyTitleLoad": "Rata-rata Beban Sistem (1 / 5 / 15 mnt)",
|
|
||||||
"historyTitleConnections": "Koneksi Aktif (TCP / UDP)",
|
|
||||||
"historyTitleDiskUsage": "Penggunaan Ruang Disk",
|
|
||||||
"historyTabBandwidth": "Bandwidth",
|
|
||||||
"historyTabPackets": "Paket",
|
|
||||||
"historyTabDisk": "Disk I/O",
|
|
||||||
"historyTabOnline": "Online",
|
|
||||||
"historyTabLoad": "Beban",
|
|
||||||
"historyTabConnections": "Koneksi",
|
|
||||||
"historyTabDiskUsage": "Penggunaan Disk",
|
|
||||||
"xrayMetricsTitle": "Metrik Xray",
|
|
||||||
"xrayTitleHeap": "Memori Heap Teralokasi",
|
|
||||||
"xrayTitleSys": "Memori Dicadangkan dari OS",
|
|
||||||
"xrayTitleObjects": "Objek Heap Aktif",
|
|
||||||
"xrayTitleGcCount": "Siklus GC Selesai",
|
|
||||||
"xrayTitleGcPause": "Durasi Jeda GC",
|
|
||||||
"xrayTitleObservatory": "Kesehatan Koneksi Keluar",
|
|
||||||
"xrayTabHeap": "Heap",
|
|
||||||
"xrayTabSys": "Sys",
|
|
||||||
"xrayTabObjects": "Objek",
|
|
||||||
"xrayTabGcCount": "Jumlah GC",
|
|
||||||
"xrayTabGcPause": "Jeda GC",
|
|
||||||
"xrayTabObservatory": "Observatorium",
|
|
||||||
"xrayMetricsDisabled": "Endpoint metrik Xray belum dikonfigurasi",
|
|
||||||
"xrayMetricsHint": "Tambahkan blok metrics tingkat atas ke konfigurasi xray dengan tag metrics_out dan listen 127.0.0.1:11111, lalu mulai ulang xray.",
|
|
||||||
"xrayObservatoryEmpty": "Belum ada data Observatory",
|
|
||||||
"xrayObservatoryHint": "Tambahkan blok observatory ke konfigurasi xray yang mencantumkan tag outbound untuk diuji, lalu mulai ulang xray.",
|
|
||||||
"xrayObservatoryTagPlaceholder": "Pilih outbound",
|
|
||||||
"xrayObservatoryAlive": "Aktif",
|
|
||||||
"xrayObservatoryDead": "Mati",
|
|
||||||
"xrayObservatoryLastSeen": "Terakhir terlihat",
|
|
||||||
"xrayObservatoryLastTry": "Percobaan terakhir",
|
|
||||||
"connectionCount": "Statistik Koneksi",
|
"connectionCount": "Statistik Koneksi",
|
||||||
"ipAddresses": "Alamat IP",
|
"ipAddresses": "Alamat IP",
|
||||||
"toggleIpVisibility": "Alihkan visibilitas IP",
|
"toggleIpVisibility": "Alihkan visibilitas IP",
|
||||||
@@ -191,18 +152,6 @@
|
|||||||
"panelUpdateUnknownTitle": "Tidak dapat memastikan pembaruan selesai",
|
"panelUpdateUnknownTitle": "Tidak dapat memastikan pembaruan selesai",
|
||||||
"panelUpdateUnknownDesc": "Panel tidak melaporkan hasil tepat waktu. Muat ulang untuk memeriksa versi saat ini, atau periksa log server.",
|
"panelUpdateUnknownDesc": "Panel tidak melaporkan hasil tepat waktu. Muat ulang untuk memeriksa versi saat ini, atau periksa log server.",
|
||||||
"dontRefresh": "Instalasi sedang berlangsung, harap jangan menyegarkan halaman ini",
|
"dontRefresh": "Instalasi sedang berlangsung, harap jangan menyegarkan halaman ini",
|
||||||
"logs": "Log",
|
|
||||||
"accessLogs": "Log Akses",
|
|
||||||
"autoUpdate": "Pembaruan Otomatis",
|
|
||||||
"amneziawgLogs": "Log AmneziaWG",
|
|
||||||
"amneziawgHandshake": "Handshake Terakhir",
|
|
||||||
"amneziawgInterface": "Antarmuka",
|
|
||||||
"amneziawgInbound": "Inbound",
|
|
||||||
"amneziawgEndpoint": "Endpoint",
|
|
||||||
"amneziawgIdle": "Idle",
|
|
||||||
"amneziawgEvents": "Peristiwa",
|
|
||||||
"amneziawgNoPeers": "Tidak ada peer AmneziaWG yang aktif",
|
|
||||||
"amneziawgNoEvents": "Belum ada peristiwa AmneziaWG yang tercatat",
|
|
||||||
"config": "Konfigurasi",
|
"config": "Konfigurasi",
|
||||||
"backupTitle": "Cadangan & Pulihkan",
|
"backupTitle": "Cadangan & Pulihkan",
|
||||||
"exportDatabase": "Cadangkan",
|
"exportDatabase": "Cadangkan",
|
||||||
@@ -219,25 +168,14 @@
|
|||||||
"importDatabasePgDesc": "Klik untuk memilih dan mengunggah cadangan PostgreSQL (.dump), basis data SQLite (.db), atau dump migrasi SQLite guna memulihkan basis data Anda. Ini menggantikan semua data saat ini.",
|
"importDatabasePgDesc": "Klik untuk memilih dan mengunggah cadangan PostgreSQL (.dump), basis data SQLite (.db), atau dump migrasi SQLite guna memulihkan basis data Anda. Ini menggantikan semua data saat ini.",
|
||||||
"migrationDownload": "Unduh migrasi",
|
"migrationDownload": "Unduh migrasi",
|
||||||
"migrationDownloadPgDesc": "Klik untuk mengunduh basis data SQLite .db yang dibuat dari data PostgreSQL Anda, siap menjalankan panel ini di SQLite.",
|
"migrationDownloadPgDesc": "Klik untuk mengunduh basis data SQLite .db yang dibuat dari data PostgreSQL Anda, siap menjalankan panel ini di SQLite.",
|
||||||
"avg": "rerata",
|
|
||||||
"peak": "puncak",
|
|
||||||
"free": "tersisa",
|
"free": "tersisa",
|
||||||
"openSockets": "soket terbuka",
|
"openSockets": "soket terbuka",
|
||||||
"throughputSub": "Total antarmuka",
|
"throughputSub": "Total antarmuka",
|
||||||
"avgWindow": "Rerata periode",
|
|
||||||
"healthWarm": "{list} — mulai tinggi",
|
"healthWarm": "{list} — mulai tinggi",
|
||||||
"healthCritical": "{list} — kritis",
|
"healthCritical": "{list} — kritis",
|
||||||
"panel": "Panel",
|
"panel": "Panel",
|
||||||
"threads": "Thread",
|
"threads": "Thread",
|
||||||
"uptime": "Waktu aktif",
|
"uptime": "Waktu aktif",
|
||||||
"logLevelDebug": "Debug",
|
|
||||||
"logLevelInfo": "Info",
|
|
||||||
"logLevelNotice": "Notice",
|
|
||||||
"logLevelWarning": "Warning",
|
|
||||||
"logLevelError": "Error",
|
|
||||||
"accessDirect": "DIRECT",
|
|
||||||
"accessBlocked": "BLOCKED",
|
|
||||||
"accessProxy": "PROXY",
|
|
||||||
"importKeepHostSettings": "Pertahankan pengaturan mesin ini",
|
"importKeepHostSettings": "Pertahankan pengaturan mesin ini",
|
||||||
"importKeepHostSettingsDesc": "Mempertahankan alamat dengar, port, path dasar, sertifikat, dan identitas node panel ini alih-alih mengambilnya dari berkas yang diunggah."
|
"importKeepHostSettingsDesc": "Mempertahankan alamat dengar, port, path dasar, sertifikat, dan identitas node panel ini alih-alih mengambilnya dari berkas yang diunggah."
|
||||||
},
|
},
|
||||||
@@ -970,7 +908,19 @@
|
|||||||
"pinnedCertPlaceholder": "SHA-256 base64 atau hex",
|
"pinnedCertPlaceholder": "SHA-256 base64 atau hex",
|
||||||
"fetchPin": "Ambil",
|
"fetchPin": "Ambil",
|
||||||
"pinFetched": "Berhasil mengambil sertifikat node saat ini",
|
"pinFetched": "Berhasil mengambil sertifikat node saat ini",
|
||||||
"pinFetchFailed": "Tidak dapat mengambil sertifikat"
|
"pinFetchFailed": "Tidak dapat mengambil sertifikat",
|
||||||
|
"nodeType": "Node Type",
|
||||||
|
"nodeTypeTraditional": "Traditional Panel Node",
|
||||||
|
"nodeTypeAgent": "Agent Lightweight Node",
|
||||||
|
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||||
|
"agentTokenTitle": "Agent Node Setup",
|
||||||
|
"agentTokenCreated": "Node {{name}} created successfully",
|
||||||
|
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||||
|
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
|
||||||
|
"agentTokenCopied": "Install command copied",
|
||||||
|
"agentTokenCopyFailed": "Copy failed, please copy manually",
|
||||||
|
"copyToken": "Copy Install Command",
|
||||||
|
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "Bawaan",
|
"defaultTag": "Bawaan",
|
||||||
@@ -1725,4 +1675,4 @@
|
|||||||
"noIpRecord": "❗ Tidak ada Catatan IP!"
|
"noIpRecord": "❗ Tidak ada Catatan IP!"
|
||||||
},
|
},
|
||||||
"email": {}
|
"email": {}
|
||||||
}
|
}
|
||||||
@@ -136,45 +136,6 @@
|
|||||||
"currentCommit": "現在のコミット",
|
"currentCommit": "現在のコミット",
|
||||||
"latestCommit": "最新のコミット",
|
"latestCommit": "最新のコミット",
|
||||||
"updateChannelChanged": "更新チャンネルを変更しました",
|
"updateChannelChanged": "更新チャンネルを変更しました",
|
||||||
"systemHistoryTitle": "システム履歴",
|
|
||||||
"historyTitleCpu": "CPU 使用率",
|
|
||||||
"historyTitleMem": "メモリ使用率",
|
|
||||||
"historyTitleNetwork": "ネットワーク帯域幅",
|
|
||||||
"historyTitlePackets": "ネットワークパケット",
|
|
||||||
"historyTitleDisk": "ディスク I/O",
|
|
||||||
"historyTitleOnline": "オンラインクライアント",
|
|
||||||
"historyTitleLoad": "システム平均負荷(1分 / 5分 / 15分)",
|
|
||||||
"historyTitleConnections": "アクティブな接続 (TCP / UDP)",
|
|
||||||
"historyTitleDiskUsage": "ディスク使用率",
|
|
||||||
"historyTabBandwidth": "帯域幅",
|
|
||||||
"historyTabPackets": "パケット",
|
|
||||||
"historyTabDisk": "ディスク I/O",
|
|
||||||
"historyTabOnline": "オンライン",
|
|
||||||
"historyTabLoad": "負荷",
|
|
||||||
"historyTabConnections": "接続数",
|
|
||||||
"historyTabDiskUsage": "ディスク使用量",
|
|
||||||
"xrayMetricsTitle": "Xray メトリクス",
|
|
||||||
"xrayTitleHeap": "割り当て済みヒープメモリ",
|
|
||||||
"xrayTitleSys": "OS から確保したメモリ",
|
|
||||||
"xrayTitleObjects": "ヒープオブジェクト数",
|
|
||||||
"xrayTitleGcCount": "完了した GC サイクル",
|
|
||||||
"xrayTitleGcPause": "GC 一時停止時間",
|
|
||||||
"xrayTitleObservatory": "アウトバウンド接続の状態",
|
|
||||||
"xrayTabHeap": "ヒープ",
|
|
||||||
"xrayTabSys": "Sys",
|
|
||||||
"xrayTabObjects": "オブジェクト",
|
|
||||||
"xrayTabGcCount": "GC 回数",
|
|
||||||
"xrayTabGcPause": "GC 一時停止",
|
|
||||||
"xrayTabObservatory": "オブザーバトリ",
|
|
||||||
"xrayMetricsDisabled": "Xray メトリクスエンドポイントが設定されていません",
|
|
||||||
"xrayMetricsHint": "xray 設定にトップレベルの metrics ブロック(tag: metrics_out、listen: 127.0.0.1:11111)を追加し、xray を再起動してください。",
|
|
||||||
"xrayObservatoryEmpty": "Observatory データはまだありません",
|
|
||||||
"xrayObservatoryHint": "xray 設定に observatory ブロックを追加し、プローブする outbound タグを列挙してから xray を再起動してください。",
|
|
||||||
"xrayObservatoryTagPlaceholder": "Outbound を選択",
|
|
||||||
"xrayObservatoryAlive": "稼働中",
|
|
||||||
"xrayObservatoryDead": "停止",
|
|
||||||
"xrayObservatoryLastSeen": "最終確認",
|
|
||||||
"xrayObservatoryLastTry": "最終試行",
|
|
||||||
"connectionCount": "接続数",
|
"connectionCount": "接続数",
|
||||||
"ipAddresses": "IPアドレス",
|
"ipAddresses": "IPアドレス",
|
||||||
"toggleIpVisibility": "IPの表示を切り替える",
|
"toggleIpVisibility": "IPの表示を切り替える",
|
||||||
@@ -191,18 +152,6 @@
|
|||||||
"panelUpdateUnknownTitle": "更新が完了したか確認できませんでした",
|
"panelUpdateUnknownTitle": "更新が完了したか確認できませんでした",
|
||||||
"panelUpdateUnknownDesc": "パネルから時間内に結果が報告されませんでした。再読み込みして現在のバージョンを確認するか、サーバーのログを確認してください。",
|
"panelUpdateUnknownDesc": "パネルから時間内に結果が報告されませんでした。再読み込みして現在のバージョンを確認するか、サーバーのログを確認してください。",
|
||||||
"dontRefresh": "インストール中、このページをリロードしないでください",
|
"dontRefresh": "インストール中、このページをリロードしないでください",
|
||||||
"logs": "ログ",
|
|
||||||
"accessLogs": "アクセスログ",
|
|
||||||
"autoUpdate": "自動更新",
|
|
||||||
"amneziawgLogs": "AmneziaWG ログ",
|
|
||||||
"amneziawgHandshake": "最終ハンドシェイク",
|
|
||||||
"amneziawgInterface": "インターフェース",
|
|
||||||
"amneziawgInbound": "インバウンド",
|
|
||||||
"amneziawgEndpoint": "エンドポイント",
|
|
||||||
"amneziawgIdle": "アイドル",
|
|
||||||
"amneziawgEvents": "イベント",
|
|
||||||
"amneziawgNoPeers": "稼働中の AmneziaWG ピアはありません",
|
|
||||||
"amneziawgNoEvents": "AmneziaWG のイベントはまだ記録されていません",
|
|
||||||
"config": "設定",
|
"config": "設定",
|
||||||
"backupTitle": "バックアップと復元",
|
"backupTitle": "バックアップと復元",
|
||||||
"exportDatabase": "バックアップ",
|
"exportDatabase": "バックアップ",
|
||||||
@@ -219,25 +168,14 @@
|
|||||||
"importDatabasePgDesc": "データベースを復元するために PostgreSQL バックアップ (.dump)、SQLite データベース (.db)、または SQLite 移行ダンプを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。",
|
"importDatabasePgDesc": "データベースを復元するために PostgreSQL バックアップ (.dump)、SQLite データベース (.db)、または SQLite 移行ダンプを選択してアップロードするにはクリックしてください。現在のすべてのデータが置き換えられます。",
|
||||||
"migrationDownload": "移行ファイルをダウンロード",
|
"migrationDownload": "移行ファイルをダウンロード",
|
||||||
"migrationDownloadPgDesc": "PostgreSQL のデータから作成した .db SQLite データベースをダウンロードします。このパネルを SQLite で実行する準備が整います。",
|
"migrationDownloadPgDesc": "PostgreSQL のデータから作成した .db SQLite データベースをダウンロードします。このパネルを SQLite で実行する準備が整います。",
|
||||||
"avg": "平均",
|
|
||||||
"peak": "ピーク",
|
|
||||||
"free": "空き",
|
"free": "空き",
|
||||||
"openSockets": "オープンソケット",
|
"openSockets": "オープンソケット",
|
||||||
"throughputSub": "インターフェース合計",
|
"throughputSub": "インターフェース合計",
|
||||||
"avgWindow": "期間平均",
|
|
||||||
"healthWarm": "{list} — 高めです",
|
"healthWarm": "{list} — 高めです",
|
||||||
"healthCritical": "{list} — 危険水準",
|
"healthCritical": "{list} — 危険水準",
|
||||||
"panel": "パネル",
|
"panel": "パネル",
|
||||||
"threads": "スレッド",
|
"threads": "スレッド",
|
||||||
"uptime": "稼働時間",
|
"uptime": "稼働時間",
|
||||||
"logLevelDebug": "Debug",
|
|
||||||
"logLevelInfo": "Info",
|
|
||||||
"logLevelNotice": "Notice",
|
|
||||||
"logLevelWarning": "Warning",
|
|
||||||
"logLevelError": "Error",
|
|
||||||
"accessDirect": "DIRECT",
|
|
||||||
"accessBlocked": "BLOCKED",
|
|
||||||
"accessProxy": "PROXY",
|
|
||||||
"importKeepHostSettings": "このマシンの設定を保持する",
|
"importKeepHostSettings": "このマシンの設定を保持する",
|
||||||
"importKeepHostSettingsDesc": "アップロードしたファイルの値ではなく、このパネルのリッスンアドレス、ポート、ベースパス、証明書、ノード ID を保持します。"
|
"importKeepHostSettingsDesc": "アップロードしたファイルの値ではなく、このパネルのリッスンアドレス、ポート、ベースパス、証明書、ノード ID を保持します。"
|
||||||
},
|
},
|
||||||
@@ -970,7 +908,19 @@
|
|||||||
"pinnedCertPlaceholder": "base64 または hex の SHA-256",
|
"pinnedCertPlaceholder": "base64 または hex の SHA-256",
|
||||||
"fetchPin": "取得",
|
"fetchPin": "取得",
|
||||||
"pinFetched": "ノードの現在の証明書を取得しました",
|
"pinFetched": "ノードの現在の証明書を取得しました",
|
||||||
"pinFetchFailed": "証明書を取得できませんでした"
|
"pinFetchFailed": "証明書を取得できませんでした",
|
||||||
|
"nodeType": "Node Type",
|
||||||
|
"nodeTypeTraditional": "Traditional Panel Node",
|
||||||
|
"nodeTypeAgent": "Agent Lightweight Node",
|
||||||
|
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||||
|
"agentTokenTitle": "Agent Node Setup",
|
||||||
|
"agentTokenCreated": "Node {{name}} created successfully",
|
||||||
|
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||||
|
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
|
||||||
|
"agentTokenCopied": "Install command copied",
|
||||||
|
"agentTokenCopyFailed": "Copy failed, please copy manually",
|
||||||
|
"copyToken": "Copy Install Command",
|
||||||
|
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "デフォルト",
|
"defaultTag": "デフォルト",
|
||||||
@@ -1725,4 +1675,4 @@
|
|||||||
"noIpRecord": "❗ IPレコードがありません!"
|
"noIpRecord": "❗ IPレコードがありません!"
|
||||||
},
|
},
|
||||||
"email": {}
|
"email": {}
|
||||||
}
|
}
|
||||||
@@ -136,45 +136,6 @@
|
|||||||
"currentCommit": "Commit atual",
|
"currentCommit": "Commit atual",
|
||||||
"latestCommit": "Último commit",
|
"latestCommit": "Último commit",
|
||||||
"updateChannelChanged": "Canal de atualização alterado",
|
"updateChannelChanged": "Canal de atualização alterado",
|
||||||
"systemHistoryTitle": "Histórico do Sistema",
|
|
||||||
"historyTitleCpu": "Uso da CPU",
|
|
||||||
"historyTitleMem": "Uso de Memória",
|
|
||||||
"historyTitleNetwork": "Largura de Banda da Rede",
|
|
||||||
"historyTitlePackets": "Pacotes de Rede",
|
|
||||||
"historyTitleDisk": "E/S de Disco",
|
|
||||||
"historyTitleOnline": "Clientes Online",
|
|
||||||
"historyTitleLoad": "Média de Carga do Sistema (1 / 5 / 15 min)",
|
|
||||||
"historyTitleConnections": "Conexões Ativas (TCP / UDP)",
|
|
||||||
"historyTitleDiskUsage": "Uso do Espaço em Disco",
|
|
||||||
"historyTabBandwidth": "Largura de Banda",
|
|
||||||
"historyTabPackets": "Pacotes",
|
|
||||||
"historyTabDisk": "Disco I/O",
|
|
||||||
"historyTabOnline": "Online",
|
|
||||||
"historyTabLoad": "Carga",
|
|
||||||
"historyTabConnections": "Conexões",
|
|
||||||
"historyTabDiskUsage": "Uso de Disco",
|
|
||||||
"xrayMetricsTitle": "Métricas do Xray",
|
|
||||||
"xrayTitleHeap": "Memória Heap Alocada",
|
|
||||||
"xrayTitleSys": "Memória Reservada do SO",
|
|
||||||
"xrayTitleObjects": "Objetos Heap Ativos",
|
|
||||||
"xrayTitleGcCount": "Ciclos de GC Concluídos",
|
|
||||||
"xrayTitleGcPause": "Duração da Pausa do GC",
|
|
||||||
"xrayTitleObservatory": "Saúde das Conexões de Saída",
|
|
||||||
"xrayTabHeap": "Heap",
|
|
||||||
"xrayTabSys": "Sys",
|
|
||||||
"xrayTabObjects": "Objetos",
|
|
||||||
"xrayTabGcCount": "Contagem GC",
|
|
||||||
"xrayTabGcPause": "Pausa GC",
|
|
||||||
"xrayTabObservatory": "Observatório",
|
|
||||||
"xrayMetricsDisabled": "Endpoint de métricas do Xray não configurado",
|
|
||||||
"xrayMetricsHint": "Adicione um bloco metrics de nível superior à configuração do xray com tag metrics_out e listen 127.0.0.1:11111, depois reinicie o xray.",
|
|
||||||
"xrayObservatoryEmpty": "Ainda não há dados do Observatory",
|
|
||||||
"xrayObservatoryHint": "Adicione um bloco observatory à configuração do xray listando as tags de outbound a sondar, depois reinicie o xray.",
|
|
||||||
"xrayObservatoryTagPlaceholder": "Selecionar outbound",
|
|
||||||
"xrayObservatoryAlive": "Ativo",
|
|
||||||
"xrayObservatoryDead": "Inativo",
|
|
||||||
"xrayObservatoryLastSeen": "Visto pela última vez",
|
|
||||||
"xrayObservatoryLastTry": "Última tentativa",
|
|
||||||
"connectionCount": "Estatísticas de Conexão",
|
"connectionCount": "Estatísticas de Conexão",
|
||||||
"ipAddresses": "Endereços IP",
|
"ipAddresses": "Endereços IP",
|
||||||
"toggleIpVisibility": "Alternar visibilidade do IP",
|
"toggleIpVisibility": "Alternar visibilidade do IP",
|
||||||
@@ -191,18 +152,6 @@
|
|||||||
"panelUpdateUnknownTitle": "Não foi possível confirmar se a atualização terminou",
|
"panelUpdateUnknownTitle": "Não foi possível confirmar se a atualização terminou",
|
||||||
"panelUpdateUnknownDesc": "O painel não retornou um resultado a tempo. Recarregue a página para verificar a versão atual, ou verifique os logs do servidor.",
|
"panelUpdateUnknownDesc": "O painel não retornou um resultado a tempo. Recarregue a página para verificar a versão atual, ou verifique os logs do servidor.",
|
||||||
"dontRefresh": "Instalação em andamento, por favor não atualize a página",
|
"dontRefresh": "Instalação em andamento, por favor não atualize a página",
|
||||||
"logs": "Logs",
|
|
||||||
"accessLogs": "Logs de acesso",
|
|
||||||
"autoUpdate": "Atualização automática",
|
|
||||||
"amneziawgLogs": "Logs do AmneziaWG",
|
|
||||||
"amneziawgHandshake": "Último handshake",
|
|
||||||
"amneziawgInterface": "Interface",
|
|
||||||
"amneziawgInbound": "Entrada",
|
|
||||||
"amneziawgEndpoint": "Endpoint",
|
|
||||||
"amneziawgIdle": "Ocioso",
|
|
||||||
"amneziawgEvents": "Eventos",
|
|
||||||
"amneziawgNoPeers": "Nenhum peer do AmneziaWG está ativo",
|
|
||||||
"amneziawgNoEvents": "Nenhum evento do AmneziaWG registrado ainda",
|
|
||||||
"config": "Configuração",
|
"config": "Configuração",
|
||||||
"backupTitle": "Backup & Restauração",
|
"backupTitle": "Backup & Restauração",
|
||||||
"exportDatabase": "Backup",
|
"exportDatabase": "Backup",
|
||||||
@@ -219,25 +168,14 @@
|
|||||||
"importDatabasePgDesc": "Clique para selecionar e enviar um backup do PostgreSQL (.dump), um banco de dados SQLite (.db) ou um dump de migração do SQLite para restaurar seu banco de dados. Isso substitui todos os dados atuais.",
|
"importDatabasePgDesc": "Clique para selecionar e enviar um backup do PostgreSQL (.dump), um banco de dados SQLite (.db) ou um dump de migração do SQLite para restaurar seu banco de dados. Isso substitui todos os dados atuais.",
|
||||||
"migrationDownload": "Baixar migração",
|
"migrationDownload": "Baixar migração",
|
||||||
"migrationDownloadPgDesc": "Clique para baixar um banco de dados SQLite .db criado a partir dos seus dados do PostgreSQL, pronto para executar este painel no SQLite.",
|
"migrationDownloadPgDesc": "Clique para baixar um banco de dados SQLite .db criado a partir dos seus dados do PostgreSQL, pronto para executar este painel no SQLite.",
|
||||||
"avg": "média",
|
|
||||||
"peak": "pico",
|
|
||||||
"free": "livre",
|
"free": "livre",
|
||||||
"openSockets": "sockets abertos",
|
"openSockets": "sockets abertos",
|
||||||
"throughputSub": "Total da interface",
|
"throughputSub": "Total da interface",
|
||||||
"avgWindow": "Média do período",
|
|
||||||
"healthWarm": "{list} — um pouco alto",
|
"healthWarm": "{list} — um pouco alto",
|
||||||
"healthCritical": "{list} — crítico",
|
"healthCritical": "{list} — crítico",
|
||||||
"panel": "Painel",
|
"panel": "Painel",
|
||||||
"threads": "Threads",
|
"threads": "Threads",
|
||||||
"uptime": "Tempo ativo",
|
"uptime": "Tempo ativo",
|
||||||
"logLevelDebug": "Debug",
|
|
||||||
"logLevelInfo": "Info",
|
|
||||||
"logLevelNotice": "Notice",
|
|
||||||
"logLevelWarning": "Warning",
|
|
||||||
"logLevelError": "Error",
|
|
||||||
"accessDirect": "DIRECT",
|
|
||||||
"accessBlocked": "BLOCKED",
|
|
||||||
"accessProxy": "PROXY",
|
|
||||||
"importKeepHostSettings": "Manter as configurações desta máquina",
|
"importKeepHostSettings": "Manter as configurações desta máquina",
|
||||||
"importKeepHostSettingsDesc": "Mantém os endereços de escuta, as portas, o caminho base, os certificados e a identidade de nó deste painel em vez de obtê-los do arquivo enviado."
|
"importKeepHostSettingsDesc": "Mantém os endereços de escuta, as portas, o caminho base, os certificados e a identidade de nó deste painel em vez de obtê-los do arquivo enviado."
|
||||||
},
|
},
|
||||||
@@ -970,7 +908,19 @@
|
|||||||
"pinnedCertPlaceholder": "SHA-256 em base64 ou hex",
|
"pinnedCertPlaceholder": "SHA-256 em base64 ou hex",
|
||||||
"fetchPin": "Obter",
|
"fetchPin": "Obter",
|
||||||
"pinFetched": "Certificado atual do nó obtido",
|
"pinFetched": "Certificado atual do nó obtido",
|
||||||
"pinFetchFailed": "Não foi possível obter o certificado"
|
"pinFetchFailed": "Não foi possível obter o certificado",
|
||||||
|
"nodeType": "Node Type",
|
||||||
|
"nodeTypeTraditional": "Traditional Panel Node",
|
||||||
|
"nodeTypeAgent": "Agent Lightweight Node",
|
||||||
|
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||||
|
"agentTokenTitle": "Agent Node Setup",
|
||||||
|
"agentTokenCreated": "Node {{name}} created successfully",
|
||||||
|
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||||
|
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
|
||||||
|
"agentTokenCopied": "Install command copied",
|
||||||
|
"agentTokenCopyFailed": "Copy failed, please copy manually",
|
||||||
|
"copyToken": "Copy Install Command",
|
||||||
|
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "Padrão",
|
"defaultTag": "Padrão",
|
||||||
@@ -1725,4 +1675,4 @@
|
|||||||
"noIpRecord": "❗ Nenhum registro de IP!"
|
"noIpRecord": "❗ Nenhum registro de IP!"
|
||||||
},
|
},
|
||||||
"email": {}
|
"email": {}
|
||||||
}
|
}
|
||||||
@@ -136,45 +136,6 @@
|
|||||||
"currentCommit": "Текущий коммит",
|
"currentCommit": "Текущий коммит",
|
||||||
"latestCommit": "Последний коммит",
|
"latestCommit": "Последний коммит",
|
||||||
"updateChannelChanged": "Канал обновления изменён",
|
"updateChannelChanged": "Канал обновления изменён",
|
||||||
"systemHistoryTitle": "История системы",
|
|
||||||
"historyTitleCpu": "Загрузка ЦП",
|
|
||||||
"historyTitleMem": "Использование памяти",
|
|
||||||
"historyTitleNetwork": "Пропускная способность сети",
|
|
||||||
"historyTitlePackets": "Сетевые пакеты",
|
|
||||||
"historyTitleDisk": "Дисковый ввод-вывод",
|
|
||||||
"historyTitleOnline": "Клиенты онлайн",
|
|
||||||
"historyTitleLoad": "Средняя нагрузка системы (1 / 5 / 15 мин)",
|
|
||||||
"historyTitleConnections": "Активные соединения (TCP / UDP)",
|
|
||||||
"historyTitleDiskUsage": "Использование дискового пространства",
|
|
||||||
"historyTabBandwidth": "Пропускная способность",
|
|
||||||
"historyTabPackets": "Пакеты",
|
|
||||||
"historyTabDisk": "Диск I/O",
|
|
||||||
"historyTabOnline": "Онлайн",
|
|
||||||
"historyTabLoad": "Нагрузка",
|
|
||||||
"historyTabConnections": "Соединения",
|
|
||||||
"historyTabDiskUsage": "Использование диска",
|
|
||||||
"xrayMetricsTitle": "Метрики Xray",
|
|
||||||
"xrayTitleHeap": "Выделенная память кучи",
|
|
||||||
"xrayTitleSys": "Память, зарезервированная у ОС",
|
|
||||||
"xrayTitleObjects": "Активные объекты кучи",
|
|
||||||
"xrayTitleGcCount": "Завершённые циклы GC",
|
|
||||||
"xrayTitleGcPause": "Длительность паузы GC",
|
|
||||||
"xrayTitleObservatory": "Состояние исходящих соединений",
|
|
||||||
"xrayTabHeap": "Куча",
|
|
||||||
"xrayTabSys": "Sys",
|
|
||||||
"xrayTabObjects": "Объекты",
|
|
||||||
"xrayTabGcCount": "Счётчик GC",
|
|
||||||
"xrayTabGcPause": "Пауза GC",
|
|
||||||
"xrayTabObservatory": "Обсерватория",
|
|
||||||
"xrayMetricsDisabled": "Конечная точка метрик Xray не настроена",
|
|
||||||
"xrayMetricsHint": "Добавьте блок metrics верхнего уровня в конфигурацию xray с tag metrics_out и listen 127.0.0.1:11111, затем перезапустите xray.",
|
|
||||||
"xrayObservatoryEmpty": "Данных Observatory пока нет",
|
|
||||||
"xrayObservatoryHint": "Добавьте блок observatory в конфигурацию xray с указанием тегов outbound для проверки, затем перезапустите xray.",
|
|
||||||
"xrayObservatoryTagPlaceholder": "Выберите outbound",
|
|
||||||
"xrayObservatoryAlive": "Активен",
|
|
||||||
"xrayObservatoryDead": "Недоступен",
|
|
||||||
"xrayObservatoryLastSeen": "Последняя активность",
|
|
||||||
"xrayObservatoryLastTry": "Последняя попытка",
|
|
||||||
"connectionCount": "Количество соединений",
|
"connectionCount": "Количество соединений",
|
||||||
"ipAddresses": "IP-адреса сервера",
|
"ipAddresses": "IP-адреса сервера",
|
||||||
"toggleIpVisibility": "Скрыть или показать IP-адреса сервера",
|
"toggleIpVisibility": "Скрыть или показать IP-адреса сервера",
|
||||||
@@ -191,18 +152,6 @@
|
|||||||
"panelUpdateUnknownTitle": "Не удалось подтвердить завершение обновления",
|
"panelUpdateUnknownTitle": "Не удалось подтвердить завершение обновления",
|
||||||
"panelUpdateUnknownDesc": "Панель не сообщила результат вовремя. Перезагрузите страницу, чтобы проверить текущую версию, или проверьте журналы сервера.",
|
"panelUpdateUnknownDesc": "Панель не сообщила результат вовремя. Перезагрузите страницу, чтобы проверить текущую версию, или проверьте журналы сервера.",
|
||||||
"dontRefresh": "Установка в процессе. Не обновляйте страницу",
|
"dontRefresh": "Установка в процессе. Не обновляйте страницу",
|
||||||
"logs": "Логи",
|
|
||||||
"accessLogs": "Логи доступа",
|
|
||||||
"autoUpdate": "Автообновление",
|
|
||||||
"amneziawgLogs": "Логи AmneziaWG",
|
|
||||||
"amneziawgHandshake": "Последнее рукопожатие",
|
|
||||||
"amneziawgInterface": "Интерфейс",
|
|
||||||
"amneziawgInbound": "Входящее",
|
|
||||||
"amneziawgEndpoint": "Точка подключения",
|
|
||||||
"amneziawgIdle": "Простой",
|
|
||||||
"amneziawgEvents": "События",
|
|
||||||
"amneziawgNoPeers": "Нет активных пиров AmneziaWG",
|
|
||||||
"amneziawgNoEvents": "События AmneziaWG пока не зафиксированы",
|
|
||||||
"config": "Конфигурация",
|
"config": "Конфигурация",
|
||||||
"backupTitle": "Бэкап и восстановление",
|
"backupTitle": "Бэкап и восстановление",
|
||||||
"exportDatabase": "Экспорт базы данных",
|
"exportDatabase": "Экспорт базы данных",
|
||||||
@@ -219,25 +168,14 @@
|
|||||||
"importDatabasePgDesc": "Нажмите, чтобы выбрать и загрузить резервную копию PostgreSQL (.dump), базу данных SQLite (.db) или миграционный дамп SQLite для восстановления базы данных. Это заменит все текущие данные.",
|
"importDatabasePgDesc": "Нажмите, чтобы выбрать и загрузить резервную копию PostgreSQL (.dump), базу данных SQLite (.db) или миграционный дамп SQLite для восстановления базы данных. Это заменит все текущие данные.",
|
||||||
"migrationDownload": "Скачать файл миграции",
|
"migrationDownload": "Скачать файл миграции",
|
||||||
"migrationDownloadPgDesc": "Нажмите, чтобы скачать базу данных SQLite (.db), собранную из ваших данных PostgreSQL и готовую для запуска панели на SQLite.",
|
"migrationDownloadPgDesc": "Нажмите, чтобы скачать базу данных SQLite (.db), собранную из ваших данных PostgreSQL и готовую для запуска панели на SQLite.",
|
||||||
"avg": "среднее",
|
|
||||||
"peak": "пик",
|
|
||||||
"free": "свободно",
|
"free": "свободно",
|
||||||
"openSockets": "открытых сокетов",
|
"openSockets": "открытых сокетов",
|
||||||
"throughputSub": "Всего по интерфейсу",
|
"throughputSub": "Всего по интерфейсу",
|
||||||
"avgWindow": "Среднее за период",
|
|
||||||
"healthWarm": "{list} — повышенная нагрузка",
|
"healthWarm": "{list} — повышенная нагрузка",
|
||||||
"healthCritical": "{list} — критический уровень",
|
"healthCritical": "{list} — критический уровень",
|
||||||
"panel": "Панель",
|
"panel": "Панель",
|
||||||
"threads": "Потоки",
|
"threads": "Потоки",
|
||||||
"uptime": "Время работы",
|
"uptime": "Время работы",
|
||||||
"logLevelDebug": "Отладка",
|
|
||||||
"logLevelInfo": "Информация",
|
|
||||||
"logLevelNotice": "Уведомление",
|
|
||||||
"logLevelWarning": "Предупреждение",
|
|
||||||
"logLevelError": "Ошибка",
|
|
||||||
"accessDirect": "НАПРЯМУЮ",
|
|
||||||
"accessBlocked": "ЗАБЛОКИРОВАНО",
|
|
||||||
"accessProxy": "ЧЕРЕЗ ПРОКСИ",
|
|
||||||
"importKeepHostSettings": "Сохранить настройки этой машины",
|
"importKeepHostSettings": "Сохранить настройки этой машины",
|
||||||
"importKeepHostSettingsDesc": "Оставляет адреса и порты этой панели, базовый путь, сертификаты и удостоверение для узлов вместо тех, что в загруженном файле."
|
"importKeepHostSettingsDesc": "Оставляет адреса и порты этой панели, базовый путь, сертификаты и удостоверение для узлов вместо тех, что в загруженном файле."
|
||||||
},
|
},
|
||||||
@@ -970,7 +908,19 @@
|
|||||||
"pinnedCertPlaceholder": "SHA-256 в base64 или hex",
|
"pinnedCertPlaceholder": "SHA-256 в base64 или hex",
|
||||||
"fetchPin": "Получить",
|
"fetchPin": "Получить",
|
||||||
"pinFetched": "Текущий сертификат узла получен",
|
"pinFetched": "Текущий сертификат узла получен",
|
||||||
"pinFetchFailed": "Не удалось получить сертификат"
|
"pinFetchFailed": "Не удалось получить сертификат",
|
||||||
|
"nodeType": "Node Type",
|
||||||
|
"nodeTypeTraditional": "Traditional Panel Node",
|
||||||
|
"nodeTypeAgent": "Agent Lightweight Node",
|
||||||
|
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||||
|
"agentTokenTitle": "Agent Node Setup",
|
||||||
|
"agentTokenCreated": "Node {{name}} created successfully",
|
||||||
|
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||||
|
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
|
||||||
|
"agentTokenCopied": "Install command copied",
|
||||||
|
"agentTokenCopyFailed": "Copy failed, please copy manually",
|
||||||
|
"copyToken": "Copy Install Command",
|
||||||
|
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "По умолчанию",
|
"defaultTag": "По умолчанию",
|
||||||
@@ -1725,4 +1675,4 @@
|
|||||||
"noIpRecord": "❗ Нет записей об IP-адресе."
|
"noIpRecord": "❗ Нет записей об IP-адресе."
|
||||||
},
|
},
|
||||||
"email": {}
|
"email": {}
|
||||||
}
|
}
|
||||||
@@ -136,45 +136,6 @@
|
|||||||
"currentCommit": "Geçerli commit",
|
"currentCommit": "Geçerli commit",
|
||||||
"latestCommit": "Son commit",
|
"latestCommit": "Son commit",
|
||||||
"updateChannelChanged": "Güncelleme kanalı değiştirildi",
|
"updateChannelChanged": "Güncelleme kanalı değiştirildi",
|
||||||
"systemHistoryTitle": "Sistem Geçmişi",
|
|
||||||
"historyTitleCpu": "CPU Kullanımı",
|
|
||||||
"historyTitleMem": "Bellek Kullanımı",
|
|
||||||
"historyTitleNetwork": "Ağ Bant Genişliği",
|
|
||||||
"historyTitlePackets": "Ağ Paketleri",
|
|
||||||
"historyTitleDisk": "Disk G/Ç",
|
|
||||||
"historyTitleOnline": "Çevrimiçi Kullanıcılar",
|
|
||||||
"historyTitleLoad": "Sistem Yük Ortalaması (1dk / 5dk / 15dk)",
|
|
||||||
"historyTitleConnections": "Etkin Bağlantılar (TCP / UDP)",
|
|
||||||
"historyTitleDiskUsage": "Disk Alanı Kullanımı",
|
|
||||||
"historyTabBandwidth": "Bant Genişliği",
|
|
||||||
"historyTabPackets": "Paketler",
|
|
||||||
"historyTabDisk": "Disk G/Ç",
|
|
||||||
"historyTabOnline": "Çevrimiçi",
|
|
||||||
"historyTabLoad": "Yük",
|
|
||||||
"historyTabConnections": "Bağlantılar",
|
|
||||||
"historyTabDiskUsage": "Disk Kullanımı",
|
|
||||||
"xrayMetricsTitle": "Xray Metrikleri",
|
|
||||||
"xrayTitleHeap": "Ayrılan Yığın Belleği",
|
|
||||||
"xrayTitleSys": "İşletim Sisteminden Ayrılan Bellek",
|
|
||||||
"xrayTitleObjects": "Aktif Yığın Nesneleri",
|
|
||||||
"xrayTitleGcCount": "Tamamlanan GC Döngüleri",
|
|
||||||
"xrayTitleGcPause": "GC Duraklama Süresi",
|
|
||||||
"xrayTitleObservatory": "Giden Bağlantı Durumu",
|
|
||||||
"xrayTabHeap": "Heap",
|
|
||||||
"xrayTabSys": "Sys",
|
|
||||||
"xrayTabObjects": "Nesneler",
|
|
||||||
"xrayTabGcCount": "GC Sayısı",
|
|
||||||
"xrayTabGcPause": "GC Duraklaması",
|
|
||||||
"xrayTabObservatory": "Gözlemevi",
|
|
||||||
"xrayMetricsDisabled": "Xray metrik uç noktası yapılandırılmadı",
|
|
||||||
"xrayMetricsHint": "Xray yapılandırmasına tag metrics_out ve listen 127.0.0.1:11111 olan üst düzey bir metrics bloğu ekleyin, sonra Xray'i yeniden başlatın.",
|
|
||||||
"xrayObservatoryEmpty": "Henüz Gözlemevi verisi yok",
|
|
||||||
"xrayObservatoryHint": "Xray yapılandırmasına test edilecek giden bağlantı etiketlerini listeleyen bir Gözlemevi bloğu ekleyin, sonra Xray'i yeniden başlatın.",
|
|
||||||
"xrayObservatoryTagPlaceholder": "Giden bağlantı seç",
|
|
||||||
"xrayObservatoryAlive": "Aktif",
|
|
||||||
"xrayObservatoryDead": "Kapalı",
|
|
||||||
"xrayObservatoryLastSeen": "Son görülme",
|
|
||||||
"xrayObservatoryLastTry": "Son deneme",
|
|
||||||
"connectionCount": "Bağlantı İstatistikleri",
|
"connectionCount": "Bağlantı İstatistikleri",
|
||||||
"ipAddresses": "IP Adresleri",
|
"ipAddresses": "IP Adresleri",
|
||||||
"toggleIpVisibility": "IP görünürlüğünü değiştir",
|
"toggleIpVisibility": "IP görünürlüğünü değiştir",
|
||||||
@@ -191,18 +152,6 @@
|
|||||||
"panelUpdateUnknownTitle": "Güncellemenin tamamlandığı doğrulanamadı",
|
"panelUpdateUnknownTitle": "Güncellemenin tamamlandığı doğrulanamadı",
|
||||||
"panelUpdateUnknownDesc": "Panel zamanında bir sonuç bildirmedi. Geçerli sürümü kontrol etmek için sayfayı yenileyin veya sunucu günlüklerini kontrol edin.",
|
"panelUpdateUnknownDesc": "Panel zamanında bir sonuç bildirmedi. Geçerli sürümü kontrol etmek için sayfayı yenileyin veya sunucu günlüklerini kontrol edin.",
|
||||||
"dontRefresh": "Kurulum devam ediyor, lütfen bu sayfayı yenilemeyin",
|
"dontRefresh": "Kurulum devam ediyor, lütfen bu sayfayı yenilemeyin",
|
||||||
"logs": "Günlükler",
|
|
||||||
"accessLogs": "Erişim Günlükleri",
|
|
||||||
"autoUpdate": "Otomatik Güncelleme",
|
|
||||||
"amneziawgLogs": "AmneziaWG Günlükleri",
|
|
||||||
"amneziawgHandshake": "Son El Sıkışma",
|
|
||||||
"amneziawgInterface": "Arayüz",
|
|
||||||
"amneziawgInbound": "Gelen",
|
|
||||||
"amneziawgEndpoint": "Uç Nokta",
|
|
||||||
"amneziawgIdle": "Boşta",
|
|
||||||
"amneziawgEvents": "Olaylar",
|
|
||||||
"amneziawgNoPeers": "Şu anda etkin AmneziaWG eşi yok",
|
|
||||||
"amneziawgNoEvents": "Henüz AmneziaWG olayı kaydedilmedi",
|
|
||||||
"config": "Yapılandırma",
|
"config": "Yapılandırma",
|
||||||
"backupTitle": "Yedekleme ve Geri Yükleme",
|
"backupTitle": "Yedekleme ve Geri Yükleme",
|
||||||
"exportDatabase": "Yedekle",
|
"exportDatabase": "Yedekle",
|
||||||
@@ -219,25 +168,14 @@
|
|||||||
"importDatabasePgDesc": "Veritabanınızı geri yüklemek için bir PostgreSQL yedeği (.dump), SQLite veritabanı (.db) veya SQLite taşıma dökümü seçip yüklemek üzere tıklayın. Bu, tüm mevcut verilerin yerini alır.",
|
"importDatabasePgDesc": "Veritabanınızı geri yüklemek için bir PostgreSQL yedeği (.dump), SQLite veritabanı (.db) veya SQLite taşıma dökümü seçip yüklemek üzere tıklayın. Bu, tüm mevcut verilerin yerini alır.",
|
||||||
"migrationDownload": "Geçiş Dosyasını İndir",
|
"migrationDownload": "Geçiş Dosyasını İndir",
|
||||||
"migrationDownloadPgDesc": "PostgreSQL verilerinizden oluşturulan ve bu paneli SQLite üzerinde çalıştırmaya hazır bir .db SQLite veritabanı indirmek için tıklayın.",
|
"migrationDownloadPgDesc": "PostgreSQL verilerinizden oluşturulan ve bu paneli SQLite üzerinde çalıştırmaya hazır bir .db SQLite veritabanı indirmek için tıklayın.",
|
||||||
"avg": "ort.",
|
|
||||||
"peak": "tepe",
|
|
||||||
"free": "boş",
|
"free": "boş",
|
||||||
"openSockets": "açık soket",
|
"openSockets": "açık soket",
|
||||||
"throughputSub": "Arayüz toplamı",
|
"throughputSub": "Arayüz toplamı",
|
||||||
"avgWindow": "Dönem ortalaması",
|
|
||||||
"healthWarm": "{list} — yükseliyor",
|
"healthWarm": "{list} — yükseliyor",
|
||||||
"healthCritical": "{list} — kritik",
|
"healthCritical": "{list} — kritik",
|
||||||
"panel": "Panel",
|
"panel": "Panel",
|
||||||
"threads": "İş parçacıkları",
|
"threads": "İş parçacıkları",
|
||||||
"uptime": "Çalışma süresi",
|
"uptime": "Çalışma süresi",
|
||||||
"logLevelDebug": "Hata Ayıklama",
|
|
||||||
"logLevelInfo": "Bilgi",
|
|
||||||
"logLevelNotice": "Duyuru",
|
|
||||||
"logLevelWarning": "Uyarı",
|
|
||||||
"logLevelError": "Hata",
|
|
||||||
"accessDirect": "DIRECT",
|
|
||||||
"accessBlocked": "BLOCKED",
|
|
||||||
"accessProxy": "PROXY",
|
|
||||||
"importKeepHostSettings": "Bu makinenin ayarlarını koru",
|
"importKeepHostSettings": "Bu makinenin ayarlarını koru",
|
||||||
"importKeepHostSettingsDesc": "Yüklenen dosyadan almak yerine bu panelin dinleme adreslerini, portlarını, temel yolunu, sertifikalarını ve düğüm kimliğini korur."
|
"importKeepHostSettingsDesc": "Yüklenen dosyadan almak yerine bu panelin dinleme adreslerini, portlarını, temel yolunu, sertifikalarını ve düğüm kimliğini korur."
|
||||||
},
|
},
|
||||||
@@ -970,7 +908,19 @@
|
|||||||
"pinnedCertPlaceholder": "base64 veya hex SHA-256",
|
"pinnedCertPlaceholder": "base64 veya hex SHA-256",
|
||||||
"fetchPin": "Getir",
|
"fetchPin": "Getir",
|
||||||
"pinFetched": "Düğümün geçerli sertifikası alındı",
|
"pinFetched": "Düğümün geçerli sertifikası alındı",
|
||||||
"pinFetchFailed": "Sertifika alınamadı"
|
"pinFetchFailed": "Sertifika alınamadı",
|
||||||
|
"nodeType": "Node Type",
|
||||||
|
"nodeTypeTraditional": "Traditional Panel Node",
|
||||||
|
"nodeTypeAgent": "Agent Lightweight Node",
|
||||||
|
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||||
|
"agentTokenTitle": "Agent Node Setup",
|
||||||
|
"agentTokenCreated": "Node {{name}} created successfully",
|
||||||
|
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||||
|
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
|
||||||
|
"agentTokenCopied": "Install command copied",
|
||||||
|
"agentTokenCopyFailed": "Copy failed, please copy manually",
|
||||||
|
"copyToken": "Copy Install Command",
|
||||||
|
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "Varsayılan",
|
"defaultTag": "Varsayılan",
|
||||||
@@ -1725,4 +1675,4 @@
|
|||||||
"noIpRecord": "❗ IP Kaydı Yok!"
|
"noIpRecord": "❗ IP Kaydı Yok!"
|
||||||
},
|
},
|
||||||
"email": {}
|
"email": {}
|
||||||
}
|
}
|
||||||
@@ -136,45 +136,6 @@
|
|||||||
"currentCommit": "Поточний коміт",
|
"currentCommit": "Поточний коміт",
|
||||||
"latestCommit": "Останній коміт",
|
"latestCommit": "Останній коміт",
|
||||||
"updateChannelChanged": "Канал оновлення змінено",
|
"updateChannelChanged": "Канал оновлення змінено",
|
||||||
"systemHistoryTitle": "Історія системи",
|
|
||||||
"historyTitleCpu": "Завантаження ЦП",
|
|
||||||
"historyTitleMem": "Використання пам’яті",
|
|
||||||
"historyTitleNetwork": "Пропускна здатність мережі",
|
|
||||||
"historyTitlePackets": "Мережеві пакети",
|
|
||||||
"historyTitleDisk": "Дисковий ввід-вивід",
|
|
||||||
"historyTitleOnline": "Клієнти онлайн",
|
|
||||||
"historyTitleLoad": "Середнє навантаження системи (1 / 5 / 15 хв)",
|
|
||||||
"historyTitleConnections": "Активні з’єднання (TCP / UDP)",
|
|
||||||
"historyTitleDiskUsage": "Використання дискового простору",
|
|
||||||
"historyTabBandwidth": "Пропускна здатність",
|
|
||||||
"historyTabPackets": "Пакети",
|
|
||||||
"historyTabDisk": "Диск I/O",
|
|
||||||
"historyTabOnline": "Онлайн",
|
|
||||||
"historyTabLoad": "Навантаження",
|
|
||||||
"historyTabConnections": "З’єднання",
|
|
||||||
"historyTabDiskUsage": "Використання диска",
|
|
||||||
"xrayMetricsTitle": "Метрики Xray",
|
|
||||||
"xrayTitleHeap": "Виділена пам’ять купи",
|
|
||||||
"xrayTitleSys": "Пам’ять, зарезервована в ОС",
|
|
||||||
"xrayTitleObjects": "Активні об’єкти купи",
|
|
||||||
"xrayTitleGcCount": "Завершені цикли GC",
|
|
||||||
"xrayTitleGcPause": "Тривалість паузи GC",
|
|
||||||
"xrayTitleObservatory": "Стан вихідних з’єднань",
|
|
||||||
"xrayTabHeap": "Купа",
|
|
||||||
"xrayTabSys": "Sys",
|
|
||||||
"xrayTabObjects": "Об’єкти",
|
|
||||||
"xrayTabGcCount": "Лічильник GC",
|
|
||||||
"xrayTabGcPause": "Пауза GC",
|
|
||||||
"xrayTabObservatory": "Обсерваторія",
|
|
||||||
"xrayMetricsDisabled": "Кінцева точка метрик Xray не налаштована",
|
|
||||||
"xrayMetricsHint": "Додайте блок metrics верхнього рівня до конфігурації xray з tag metrics_out і listen 127.0.0.1:11111, потім перезапустіть xray.",
|
|
||||||
"xrayObservatoryEmpty": "Даних Observatory ще немає",
|
|
||||||
"xrayObservatoryHint": "Додайте блок observatory до конфігурації xray зі списком outbound тегів для перевірки, потім перезапустіть xray.",
|
|
||||||
"xrayObservatoryTagPlaceholder": "Виберіть outbound",
|
|
||||||
"xrayObservatoryAlive": "Активний",
|
|
||||||
"xrayObservatoryDead": "Недоступний",
|
|
||||||
"xrayObservatoryLastSeen": "Остання активність",
|
|
||||||
"xrayObservatoryLastTry": "Остання спроба",
|
|
||||||
"connectionCount": "Статистика з'єднання",
|
"connectionCount": "Статистика з'єднання",
|
||||||
"ipAddresses": "IP-адреси",
|
"ipAddresses": "IP-адреси",
|
||||||
"toggleIpVisibility": "Перемкнути видимість IP",
|
"toggleIpVisibility": "Перемкнути видимість IP",
|
||||||
@@ -191,18 +152,6 @@
|
|||||||
"panelUpdateUnknownTitle": "Не вдалося підтвердити завершення оновлення",
|
"panelUpdateUnknownTitle": "Не вдалося підтвердити завершення оновлення",
|
||||||
"panelUpdateUnknownDesc": "Панель не повідомила результат вчасно. Перезавантажте сторінку, щоб перевірити поточну версію, або перевірте журнали сервера.",
|
"panelUpdateUnknownDesc": "Панель не повідомила результат вчасно. Перезавантажте сторінку, щоб перевірити поточну версію, або перевірте журнали сервера.",
|
||||||
"dontRefresh": "Інсталяція триває, будь ласка, не оновлюйте цю сторінку",
|
"dontRefresh": "Інсталяція триває, будь ласка, не оновлюйте цю сторінку",
|
||||||
"logs": "Логи",
|
|
||||||
"accessLogs": "Логи доступу",
|
|
||||||
"autoUpdate": "Автооновлення",
|
|
||||||
"amneziawgLogs": "Логи AmneziaWG",
|
|
||||||
"amneziawgHandshake": "Останнє рукостискання",
|
|
||||||
"amneziawgInterface": "Інтерфейс",
|
|
||||||
"amneziawgInbound": "Вхідне",
|
|
||||||
"amneziawgEndpoint": "Точка підключення",
|
|
||||||
"amneziawgIdle": "Очікування",
|
|
||||||
"amneziawgEvents": "Події",
|
|
||||||
"amneziawgNoPeers": "Немає активних пірів AmneziaWG",
|
|
||||||
"amneziawgNoEvents": "Подій AmneziaWG ще не зафіксовано",
|
|
||||||
"config": "Конфігурація",
|
"config": "Конфігурація",
|
||||||
"backupTitle": "Резервне копіювання та відновлення",
|
"backupTitle": "Резервне копіювання та відновлення",
|
||||||
"exportDatabase": "Резервна копія",
|
"exportDatabase": "Резервна копія",
|
||||||
@@ -219,25 +168,14 @@
|
|||||||
"importDatabasePgDesc": "Натисніть, щоб вибрати та завантажити резервну копію PostgreSQL (.dump), базу даних SQLite (.db) або міграційний дамп SQLite для відновлення бази даних. Це замінить усі поточні дані.",
|
"importDatabasePgDesc": "Натисніть, щоб вибрати та завантажити резервну копію PostgreSQL (.dump), базу даних SQLite (.db) або міграційний дамп SQLite для відновлення бази даних. Це замінить усі поточні дані.",
|
||||||
"migrationDownload": "Завантажити файл міграції",
|
"migrationDownload": "Завантажити файл міграції",
|
||||||
"migrationDownloadPgDesc": "Натисніть, щоб завантажити базу даних SQLite (.db), створену з ваших даних PostgreSQL і готову для запуску панелі на SQLite.",
|
"migrationDownloadPgDesc": "Натисніть, щоб завантажити базу даних SQLite (.db), створену з ваших даних PostgreSQL і готову для запуску панелі на SQLite.",
|
||||||
"avg": "середнє",
|
|
||||||
"peak": "пік",
|
|
||||||
"free": "вільно",
|
"free": "вільно",
|
||||||
"openSockets": "відкритих сокетів",
|
"openSockets": "відкритих сокетів",
|
||||||
"throughputSub": "Разом за інтерфейсом",
|
"throughputSub": "Разом за інтерфейсом",
|
||||||
"avgWindow": "Середнє за період",
|
|
||||||
"healthWarm": "{list} — підвищене навантаження",
|
"healthWarm": "{list} — підвищене навантаження",
|
||||||
"healthCritical": "{list} — критичний рівень",
|
"healthCritical": "{list} — критичний рівень",
|
||||||
"panel": "Панель",
|
"panel": "Панель",
|
||||||
"threads": "Потоки",
|
"threads": "Потоки",
|
||||||
"uptime": "Час роботи",
|
"uptime": "Час роботи",
|
||||||
"logLevelDebug": "Налагодження",
|
|
||||||
"logLevelInfo": "Інформація",
|
|
||||||
"logLevelNotice": "Сповіщення",
|
|
||||||
"logLevelWarning": "Попередження",
|
|
||||||
"logLevelError": "Помилка",
|
|
||||||
"accessDirect": "НАПРЯМУ",
|
|
||||||
"accessBlocked": "ЗАБЛОКОВАНО",
|
|
||||||
"accessProxy": "ЧЕРЕЗ ПРОКСІ",
|
|
||||||
"importKeepHostSettings": "Зберегти налаштування цієї машини",
|
"importKeepHostSettings": "Зберегти налаштування цієї машини",
|
||||||
"importKeepHostSettingsDesc": "Залишає адреси та порти цієї панелі, базовий шлях, сертифікати та посвідчення для вузлів замість тих, що у завантаженому файлі."
|
"importKeepHostSettingsDesc": "Залишає адреси та порти цієї панелі, базовий шлях, сертифікати та посвідчення для вузлів замість тих, що у завантаженому файлі."
|
||||||
},
|
},
|
||||||
@@ -970,7 +908,19 @@
|
|||||||
"pinnedCertPlaceholder": "SHA-256 у base64 або hex",
|
"pinnedCertPlaceholder": "SHA-256 у base64 або hex",
|
||||||
"fetchPin": "Отримати",
|
"fetchPin": "Отримати",
|
||||||
"pinFetched": "Поточний сертифікат вузла отримано",
|
"pinFetched": "Поточний сертифікат вузла отримано",
|
||||||
"pinFetchFailed": "Не вдалося отримати сертифікат"
|
"pinFetchFailed": "Не вдалося отримати сертифікат",
|
||||||
|
"nodeType": "Node Type",
|
||||||
|
"nodeTypeTraditional": "Traditional Panel Node",
|
||||||
|
"nodeTypeAgent": "Agent Lightweight Node",
|
||||||
|
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||||
|
"agentTokenTitle": "Agent Node Setup",
|
||||||
|
"agentTokenCreated": "Node {{name}} created successfully",
|
||||||
|
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||||
|
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
|
||||||
|
"agentTokenCopied": "Install command copied",
|
||||||
|
"agentTokenCopyFailed": "Copy failed, please copy manually",
|
||||||
|
"copyToken": "Copy Install Command",
|
||||||
|
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "Типово",
|
"defaultTag": "Типово",
|
||||||
@@ -1725,4 +1675,4 @@
|
|||||||
"noIpRecord": "❗ Немає запису IP!"
|
"noIpRecord": "❗ Немає запису IP!"
|
||||||
},
|
},
|
||||||
"email": {}
|
"email": {}
|
||||||
}
|
}
|
||||||
@@ -136,45 +136,6 @@
|
|||||||
"currentCommit": "Commit hiện tại",
|
"currentCommit": "Commit hiện tại",
|
||||||
"latestCommit": "Commit mới nhất",
|
"latestCommit": "Commit mới nhất",
|
||||||
"updateChannelChanged": "Đã đổi kênh cập nhật",
|
"updateChannelChanged": "Đã đổi kênh cập nhật",
|
||||||
"systemHistoryTitle": "Lịch sử hệ thống",
|
|
||||||
"historyTitleCpu": "Mức sử dụng CPU",
|
|
||||||
"historyTitleMem": "Mức sử dụng bộ nhớ",
|
|
||||||
"historyTitleNetwork": "Băng thông mạng",
|
|
||||||
"historyTitlePackets": "Gói tin mạng",
|
|
||||||
"historyTitleDisk": "I/O đĩa",
|
|
||||||
"historyTitleOnline": "Máy khách trực tuyến",
|
|
||||||
"historyTitleLoad": "Tải trung bình hệ thống (1 / 5 / 15 phút)",
|
|
||||||
"historyTitleConnections": "Kết nối đang hoạt động (TCP / UDP)",
|
|
||||||
"historyTitleDiskUsage": "Sử dụng dung lượng đĩa",
|
|
||||||
"historyTabBandwidth": "Băng thông",
|
|
||||||
"historyTabPackets": "Gói tin",
|
|
||||||
"historyTabDisk": "Đĩa I/O",
|
|
||||||
"historyTabOnline": "Trực tuyến",
|
|
||||||
"historyTabLoad": "Tải",
|
|
||||||
"historyTabConnections": "Kết nối",
|
|
||||||
"historyTabDiskUsage": "Sử dụng đĩa",
|
|
||||||
"xrayMetricsTitle": "Chỉ số Xray",
|
|
||||||
"xrayTitleHeap": "Bộ nhớ Heap đã cấp phát",
|
|
||||||
"xrayTitleSys": "Bộ nhớ dành riêng từ HĐH",
|
|
||||||
"xrayTitleObjects": "Đối tượng Heap đang hoạt động",
|
|
||||||
"xrayTitleGcCount": "Chu kỳ GC đã hoàn thành",
|
|
||||||
"xrayTitleGcPause": "Thời lượng tạm dừng GC",
|
|
||||||
"xrayTitleObservatory": "Tình trạng kết nối đi",
|
|
||||||
"xrayTabHeap": "Heap",
|
|
||||||
"xrayTabSys": "Sys",
|
|
||||||
"xrayTabObjects": "Đối tượng",
|
|
||||||
"xrayTabGcCount": "Số lần GC",
|
|
||||||
"xrayTabGcPause": "Tạm dừng GC",
|
|
||||||
"xrayTabObservatory": "Đài quan sát",
|
|
||||||
"xrayMetricsDisabled": "Điểm cuối chỉ số Xray chưa được cấu hình",
|
|
||||||
"xrayMetricsHint": "Thêm khối metrics cấp cao nhất vào cấu hình xray với tag là metrics_out và listen là 127.0.0.1:11111, sau đó khởi động lại xray.",
|
|
||||||
"xrayObservatoryEmpty": "Chưa có dữ liệu Observatory",
|
|
||||||
"xrayObservatoryHint": "Thêm khối observatory vào cấu hình xray liệt kê các tag outbound cần kiểm tra, sau đó khởi động lại xray.",
|
|
||||||
"xrayObservatoryTagPlaceholder": "Chọn outbound",
|
|
||||||
"xrayObservatoryAlive": "Hoạt động",
|
|
||||||
"xrayObservatoryDead": "Ngừng",
|
|
||||||
"xrayObservatoryLastSeen": "Lần cuối thấy",
|
|
||||||
"xrayObservatoryLastTry": "Lần thử cuối",
|
|
||||||
"connectionCount": "Số lượng kết nối",
|
"connectionCount": "Số lượng kết nối",
|
||||||
"ipAddresses": "Địa chỉ IP",
|
"ipAddresses": "Địa chỉ IP",
|
||||||
"toggleIpVisibility": "Chuyển đổi hiển thị IP",
|
"toggleIpVisibility": "Chuyển đổi hiển thị IP",
|
||||||
@@ -191,18 +152,6 @@
|
|||||||
"panelUpdateUnknownTitle": "Không thể xác nhận việc cập nhật đã hoàn tất",
|
"panelUpdateUnknownTitle": "Không thể xác nhận việc cập nhật đã hoàn tất",
|
||||||
"panelUpdateUnknownDesc": "Panel không báo cáo kết quả kịp thời. Hãy tải lại trang để kiểm tra phiên bản hiện tại, hoặc kiểm tra nhật ký máy chủ.",
|
"panelUpdateUnknownDesc": "Panel không báo cáo kết quả kịp thời. Hãy tải lại trang để kiểm tra phiên bản hiện tại, hoặc kiểm tra nhật ký máy chủ.",
|
||||||
"dontRefresh": "Đang tiến hành cài đặt, vui lòng không làm mới trang này.",
|
"dontRefresh": "Đang tiến hành cài đặt, vui lòng không làm mới trang này.",
|
||||||
"logs": "Nhật ký",
|
|
||||||
"accessLogs": "Nhật ký truy cập",
|
|
||||||
"autoUpdate": "Tự động cập nhật",
|
|
||||||
"amneziawgLogs": "Nhật ký AmneziaWG",
|
|
||||||
"amneziawgHandshake": "Bắt tay gần nhất",
|
|
||||||
"amneziawgInterface": "Giao diện mạng",
|
|
||||||
"amneziawgInbound": "Inbound",
|
|
||||||
"amneziawgEndpoint": "Điểm cuối",
|
|
||||||
"amneziawgIdle": "Nhàn rỗi",
|
|
||||||
"amneziawgEvents": "Sự kiện",
|
|
||||||
"amneziawgNoPeers": "Hiện không có peer AmneziaWG nào hoạt động",
|
|
||||||
"amneziawgNoEvents": "Chưa ghi nhận sự kiện AmneziaWG nào",
|
|
||||||
"config": "Cấu hình",
|
"config": "Cấu hình",
|
||||||
"backupTitle": "Sao lưu & Khôi phục",
|
"backupTitle": "Sao lưu & Khôi phục",
|
||||||
"exportDatabase": "Sao lưu",
|
"exportDatabase": "Sao lưu",
|
||||||
@@ -219,25 +168,14 @@
|
|||||||
"importDatabasePgDesc": "Nhấn để chọn và tải lên bản sao lưu PostgreSQL (.dump), cơ sở dữ liệu SQLite (.db) hoặc tệp kết xuất di trú SQLite nhằm khôi phục cơ sở dữ liệu của bạn. Thao tác này sẽ thay thế toàn bộ dữ liệu hiện tại.",
|
"importDatabasePgDesc": "Nhấn để chọn và tải lên bản sao lưu PostgreSQL (.dump), cơ sở dữ liệu SQLite (.db) hoặc tệp kết xuất di trú SQLite nhằm khôi phục cơ sở dữ liệu của bạn. Thao tác này sẽ thay thế toàn bộ dữ liệu hiện tại.",
|
||||||
"migrationDownload": "Tải tệp di trú",
|
"migrationDownload": "Tải tệp di trú",
|
||||||
"migrationDownloadPgDesc": "Nhấp để tải xuống cơ sở dữ liệu SQLite .db được tạo từ dữ liệu PostgreSQL của bạn, sẵn sàng chạy bảng điều khiển này trên SQLite.",
|
"migrationDownloadPgDesc": "Nhấp để tải xuống cơ sở dữ liệu SQLite .db được tạo từ dữ liệu PostgreSQL của bạn, sẵn sàng chạy bảng điều khiển này trên SQLite.",
|
||||||
"avg": "TB",
|
|
||||||
"peak": "đỉnh",
|
|
||||||
"free": "trống",
|
"free": "trống",
|
||||||
"openSockets": "socket đang mở",
|
"openSockets": "socket đang mở",
|
||||||
"throughputSub": "Tổng theo giao diện mạng",
|
"throughputSub": "Tổng theo giao diện mạng",
|
||||||
"avgWindow": "Trung bình trong khoảng",
|
|
||||||
"healthWarm": "{list} — đang cao",
|
"healthWarm": "{list} — đang cao",
|
||||||
"healthCritical": "{list} — nguy cấp",
|
"healthCritical": "{list} — nguy cấp",
|
||||||
"panel": "Panel",
|
"panel": "Panel",
|
||||||
"threads": "Luồng",
|
"threads": "Luồng",
|
||||||
"uptime": "Thời gian chạy",
|
"uptime": "Thời gian chạy",
|
||||||
"logLevelDebug": "Debug",
|
|
||||||
"logLevelInfo": "Info",
|
|
||||||
"logLevelNotice": "Notice",
|
|
||||||
"logLevelWarning": "Warning",
|
|
||||||
"logLevelError": "Error",
|
|
||||||
"accessDirect": "DIRECT",
|
|
||||||
"accessBlocked": "BLOCKED",
|
|
||||||
"accessProxy": "PROXY",
|
|
||||||
"importKeepHostSettings": "Giữ cài đặt của máy này",
|
"importKeepHostSettings": "Giữ cài đặt của máy này",
|
||||||
"importKeepHostSettingsDesc": "Giữ nguyên địa chỉ lắng nghe, cổng, đường dẫn cơ sở, chứng chỉ và danh tính node của bảng điều khiển này thay vì lấy chúng từ tệp đã tải lên."
|
"importKeepHostSettingsDesc": "Giữ nguyên địa chỉ lắng nghe, cổng, đường dẫn cơ sở, chứng chỉ và danh tính node của bảng điều khiển này thay vì lấy chúng từ tệp đã tải lên."
|
||||||
},
|
},
|
||||||
@@ -970,7 +908,19 @@
|
|||||||
"pinnedCertPlaceholder": "SHA-256 base64 hoặc hex",
|
"pinnedCertPlaceholder": "SHA-256 base64 hoặc hex",
|
||||||
"fetchPin": "Lấy",
|
"fetchPin": "Lấy",
|
||||||
"pinFetched": "Đã lấy chứng chỉ hiện tại của node",
|
"pinFetched": "Đã lấy chứng chỉ hiện tại của node",
|
||||||
"pinFetchFailed": "Không thể lấy chứng chỉ"
|
"pinFetchFailed": "Không thể lấy chứng chỉ",
|
||||||
|
"nodeType": "Node Type",
|
||||||
|
"nodeTypeTraditional": "Traditional Panel Node",
|
||||||
|
"nodeTypeAgent": "Agent Lightweight Node",
|
||||||
|
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||||
|
"agentTokenTitle": "Agent Node Setup",
|
||||||
|
"agentTokenCreated": "Node {{name}} created successfully",
|
||||||
|
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||||
|
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
|
||||||
|
"agentTokenCopied": "Install command copied",
|
||||||
|
"agentTokenCopyFailed": "Copy failed, please copy manually",
|
||||||
|
"copyToken": "Copy Install Command",
|
||||||
|
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "Mặc định",
|
"defaultTag": "Mặc định",
|
||||||
@@ -1725,4 +1675,4 @@
|
|||||||
"noIpRecord": "❗ Không có bản ghi IP!"
|
"noIpRecord": "❗ Không có bản ghi IP!"
|
||||||
},
|
},
|
||||||
"email": {}
|
"email": {}
|
||||||
}
|
}
|
||||||
@@ -136,45 +136,6 @@
|
|||||||
"currentCommit": "当前提交",
|
"currentCommit": "当前提交",
|
||||||
"latestCommit": "最新提交",
|
"latestCommit": "最新提交",
|
||||||
"updateChannelChanged": "更新通道已切换",
|
"updateChannelChanged": "更新通道已切换",
|
||||||
"systemHistoryTitle": "系统历史",
|
|
||||||
"historyTitleCpu": "CPU 使用率",
|
|
||||||
"historyTitleMem": "内存使用率",
|
|
||||||
"historyTitleNetwork": "网络带宽",
|
|
||||||
"historyTitlePackets": "网络数据包",
|
|
||||||
"historyTitleDisk": "磁盘 I/O",
|
|
||||||
"historyTitleOnline": "在线客户端",
|
|
||||||
"historyTitleLoad": "系统平均负载(1 分钟 / 5 分钟 / 15 分钟)",
|
|
||||||
"historyTitleConnections": "活动连接 (TCP / UDP)",
|
|
||||||
"historyTitleDiskUsage": "磁盘空间使用率",
|
|
||||||
"historyTabBandwidth": "带宽",
|
|
||||||
"historyTabPackets": "数据包",
|
|
||||||
"historyTabDisk": "磁盘 I/O",
|
|
||||||
"historyTabOnline": "在线",
|
|
||||||
"historyTabLoad": "负载",
|
|
||||||
"historyTabConnections": "连接数",
|
|
||||||
"historyTabDiskUsage": "磁盘使用量",
|
|
||||||
"xrayMetricsTitle": "Xray 指标",
|
|
||||||
"xrayTitleHeap": "已分配的堆内存",
|
|
||||||
"xrayTitleSys": "向操作系统保留的内存",
|
|
||||||
"xrayTitleObjects": "存活的堆对象",
|
|
||||||
"xrayTitleGcCount": "已完成的 GC 周期",
|
|
||||||
"xrayTitleGcPause": "GC 暂停时间",
|
|
||||||
"xrayTitleObservatory": "出站连接健康状态",
|
|
||||||
"xrayTabHeap": "堆",
|
|
||||||
"xrayTabSys": "系统",
|
|
||||||
"xrayTabObjects": "对象",
|
|
||||||
"xrayTabGcCount": "GC 次数",
|
|
||||||
"xrayTabGcPause": "GC 暂停",
|
|
||||||
"xrayTabObservatory": "观测站",
|
|
||||||
"xrayMetricsDisabled": "未配置 Xray 指标端点",
|
|
||||||
"xrayMetricsHint": "在 xray 配置中添加顶级 metrics 块,tag 为 metrics_out,listen 为 127.0.0.1:11111,然后重启 xray。",
|
|
||||||
"xrayObservatoryEmpty": "暂无 Observatory 数据",
|
|
||||||
"xrayObservatoryHint": "在 xray 配置中添加 observatory 块,列出要探测的出站 tag,然后重启 xray。",
|
|
||||||
"xrayObservatoryTagPlaceholder": "选择出站",
|
|
||||||
"xrayObservatoryAlive": "在线",
|
|
||||||
"xrayObservatoryDead": "离线",
|
|
||||||
"xrayObservatoryLastSeen": "最后在线",
|
|
||||||
"xrayObservatoryLastTry": "最后尝试",
|
|
||||||
"connectionCount": "连接数",
|
"connectionCount": "连接数",
|
||||||
"ipAddresses": "IP 地址",
|
"ipAddresses": "IP 地址",
|
||||||
"toggleIpVisibility": "切换 IP 可见性",
|
"toggleIpVisibility": "切换 IP 可见性",
|
||||||
@@ -191,18 +152,6 @@
|
|||||||
"panelUpdateUnknownTitle": "无法确认更新是否已完成",
|
"panelUpdateUnknownTitle": "无法确认更新是否已完成",
|
||||||
"panelUpdateUnknownDesc": "面板未能及时返回结果。请刷新页面查看当前版本,或检查服务器日志。",
|
"panelUpdateUnknownDesc": "面板未能及时返回结果。请刷新页面查看当前版本,或检查服务器日志。",
|
||||||
"dontRefresh": "安装中,请勿刷新此页面",
|
"dontRefresh": "安装中,请勿刷新此页面",
|
||||||
"logs": "日志",
|
|
||||||
"accessLogs": "访问日志",
|
|
||||||
"autoUpdate": "自动更新",
|
|
||||||
"amneziawgLogs": "AmneziaWG 日志",
|
|
||||||
"amneziawgHandshake": "最后握手",
|
|
||||||
"amneziawgInterface": "网络接口",
|
|
||||||
"amneziawgInbound": "入站",
|
|
||||||
"amneziawgEndpoint": "端点",
|
|
||||||
"amneziawgIdle": "空闲",
|
|
||||||
"amneziawgEvents": "事件",
|
|
||||||
"amneziawgNoPeers": "当前没有活动的 AmneziaWG 对端",
|
|
||||||
"amneziawgNoEvents": "尚未记录 AmneziaWG 事件",
|
|
||||||
"config": "配置",
|
"config": "配置",
|
||||||
"backupTitle": "备份和恢复",
|
"backupTitle": "备份和恢复",
|
||||||
"exportDatabase": "备份",
|
"exportDatabase": "备份",
|
||||||
@@ -219,25 +168,14 @@
|
|||||||
"importDatabasePgDesc": "点击选择并上传 PostgreSQL 备份(.dump)、SQLite 数据库(.db)或 SQLite 迁移导出文件以恢复您的数据库。此操作将替换所有当前数据。",
|
"importDatabasePgDesc": "点击选择并上传 PostgreSQL 备份(.dump)、SQLite 数据库(.db)或 SQLite 迁移导出文件以恢复您的数据库。此操作将替换所有当前数据。",
|
||||||
"migrationDownload": "下载迁移文件",
|
"migrationDownload": "下载迁移文件",
|
||||||
"migrationDownloadPgDesc": "点击下载由 PostgreSQL 数据构建的 .db SQLite 数据库,可用于在 SQLite 上运行本面板。",
|
"migrationDownloadPgDesc": "点击下载由 PostgreSQL 数据构建的 .db SQLite 数据库,可用于在 SQLite 上运行本面板。",
|
||||||
"avg": "均值",
|
|
||||||
"peak": "峰值",
|
|
||||||
"free": "剩余",
|
"free": "剩余",
|
||||||
"openSockets": "打开的套接字",
|
"openSockets": "打开的套接字",
|
||||||
"throughputSub": "网卡总计",
|
"throughputSub": "网卡总计",
|
||||||
"avgWindow": "窗口均值",
|
|
||||||
"healthWarm": "{list} — 偏高",
|
"healthWarm": "{list} — 偏高",
|
||||||
"healthCritical": "{list} — 危险",
|
"healthCritical": "{list} — 危险",
|
||||||
"panel": "面板",
|
"panel": "面板",
|
||||||
"threads": "线程",
|
"threads": "线程",
|
||||||
"uptime": "运行时间",
|
"uptime": "运行时间",
|
||||||
"logLevelDebug": "Debug",
|
|
||||||
"logLevelInfo": "Info",
|
|
||||||
"logLevelNotice": "Notice",
|
|
||||||
"logLevelWarning": "Warning",
|
|
||||||
"logLevelError": "Error",
|
|
||||||
"accessDirect": "DIRECT",
|
|
||||||
"accessBlocked": "BLOCKED",
|
|
||||||
"accessProxy": "PROXY",
|
|
||||||
"importKeepHostSettings": "保留本机设置",
|
"importKeepHostSettings": "保留本机设置",
|
||||||
"importKeepHostSettingsDesc": "保留本面板的监听地址、端口、基础路径、证书和节点身份,而不是使用上传文件中的值。"
|
"importKeepHostSettingsDesc": "保留本面板的监听地址、端口、基础路径、证书和节点身份,而不是使用上传文件中的值。"
|
||||||
},
|
},
|
||||||
@@ -970,7 +908,19 @@
|
|||||||
"pinnedCertPlaceholder": "base64 或 hex 的 SHA-256",
|
"pinnedCertPlaceholder": "base64 或 hex 的 SHA-256",
|
||||||
"fetchPin": "获取",
|
"fetchPin": "获取",
|
||||||
"pinFetched": "已获取节点当前证书",
|
"pinFetched": "已获取节点当前证书",
|
||||||
"pinFetchFailed": "无法获取证书"
|
"pinFetchFailed": "无法获取证书",
|
||||||
|
"nodeType": "节点类型",
|
||||||
|
"nodeTypeTraditional": "传统面板节点",
|
||||||
|
"nodeTypeAgent": "Agent 轻量节点",
|
||||||
|
"agentHint": "Agent 节点由节点机主动连接主控,无需填写地址、端口和 API 令牌。创建后会生成安装命令。",
|
||||||
|
"agentTokenTitle": "Agent 节点安装",
|
||||||
|
"agentTokenCreated": "节点 {{name}} 创建成功",
|
||||||
|
"agentTokenHint": "复制下面的命令,在节点机上运行即可完成安装。此 token 只显示一次!",
|
||||||
|
"agentTokenInstructions": "命令会自动下载安装脚本、配置主控地址和 token,约 30 秒后节点上线。",
|
||||||
|
"agentTokenCopied": "安装命令已复制",
|
||||||
|
"agentTokenCopyFailed": "复制失败,请手动复制",
|
||||||
|
"copyToken": "复制安装命令",
|
||||||
|
"agentMasterUrlHint": "主控地址(agent 用来连接主控,如有域名或反代请改成对外地址):"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "默认",
|
"defaultTag": "默认",
|
||||||
@@ -1070,7 +1020,7 @@
|
|||||||
"subListen": "监听 IP",
|
"subListen": "监听 IP",
|
||||||
"subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP)",
|
"subListenDesc": "订阅服务监听的 IP 地址(留空表示监听所有 IP)",
|
||||||
"subPort": "监听端口",
|
"subPort": "监听端口",
|
||||||
"subPortDesc": "订阅服务监听的端口号(必须是未使用的端口)。当下方「反向代理 URI」为空时,也会用来生成面板中显示的订阅链接/二维码——如果订阅是通过反向代理的其他端口访问的,请改为设置「反向代理 URI」。",
|
"subPortDesc": "订阅服务监听的端口号(必须是未使用的端口)。当下方「反向代理 URI」为空时,也会用来生成面板中显示的订阅链接/二维码——如果订阅是通过反向代理的其他端口访问的,请改为设置「反向代理 URI」。留空表示订阅链接不带端口(服务仍监听默认端口 2096)。",
|
||||||
"subCertPath": "公钥路径",
|
"subCertPath": "公钥路径",
|
||||||
"subCertPathDesc": "订阅服务使用的公钥文件路径(以 '/' 开头)",
|
"subCertPathDesc": "订阅服务使用的公钥文件路径(以 '/' 开头)",
|
||||||
"subKeyPath": "私钥路径",
|
"subKeyPath": "私钥路径",
|
||||||
@@ -1725,4 +1675,4 @@
|
|||||||
"noIpRecord": "❗ 没有 IP 记录!"
|
"noIpRecord": "❗ 没有 IP 记录!"
|
||||||
},
|
},
|
||||||
"email": {}
|
"email": {}
|
||||||
}
|
}
|
||||||
@@ -136,45 +136,6 @@
|
|||||||
"currentCommit": "目前提交",
|
"currentCommit": "目前提交",
|
||||||
"latestCommit": "最新提交",
|
"latestCommit": "最新提交",
|
||||||
"updateChannelChanged": "更新通道已切換",
|
"updateChannelChanged": "更新通道已切換",
|
||||||
"systemHistoryTitle": "系統歷史",
|
|
||||||
"historyTitleCpu": "CPU 使用率",
|
|
||||||
"historyTitleMem": "記憶體使用率",
|
|
||||||
"historyTitleNetwork": "網路頻寬",
|
|
||||||
"historyTitlePackets": "網路封包",
|
|
||||||
"historyTitleDisk": "磁碟 I/O",
|
|
||||||
"historyTitleOnline": "線上用戶端",
|
|
||||||
"historyTitleLoad": "系統平均負載(1 分鐘 / 5 分鐘 / 15 分鐘)",
|
|
||||||
"historyTitleConnections": "使用中的連線 (TCP / UDP)",
|
|
||||||
"historyTitleDiskUsage": "磁碟空間使用率",
|
|
||||||
"historyTabBandwidth": "頻寬",
|
|
||||||
"historyTabPackets": "封包",
|
|
||||||
"historyTabDisk": "磁碟 I/O",
|
|
||||||
"historyTabOnline": "線上",
|
|
||||||
"historyTabLoad": "負載",
|
|
||||||
"historyTabConnections": "連線數",
|
|
||||||
"historyTabDiskUsage": "磁碟使用量",
|
|
||||||
"xrayMetricsTitle": "Xray 指標",
|
|
||||||
"xrayTitleHeap": "已配置的堆積記憶體",
|
|
||||||
"xrayTitleSys": "向作業系統保留的記憶體",
|
|
||||||
"xrayTitleObjects": "存活的堆積物件",
|
|
||||||
"xrayTitleGcCount": "已完成的 GC 週期",
|
|
||||||
"xrayTitleGcPause": "GC 暫停時間",
|
|
||||||
"xrayTitleObservatory": "出站連線健康狀態",
|
|
||||||
"xrayTabHeap": "堆積",
|
|
||||||
"xrayTabSys": "系統",
|
|
||||||
"xrayTabObjects": "物件",
|
|
||||||
"xrayTabGcCount": "GC 次數",
|
|
||||||
"xrayTabGcPause": "GC 暫停",
|
|
||||||
"xrayTabObservatory": "觀測站",
|
|
||||||
"xrayMetricsDisabled": "未設定 Xray 指標端點",
|
|
||||||
"xrayMetricsHint": "在 xray 設定中加入頂層 metrics 區塊,tag 為 metrics_out,listen 為 127.0.0.1:11111,然後重啟 xray。",
|
|
||||||
"xrayObservatoryEmpty": "尚無 Observatory 資料",
|
|
||||||
"xrayObservatoryHint": "在 xray 設定中加入 observatory 區塊,列出要探測的出站 tag,然後重啟 xray。",
|
|
||||||
"xrayObservatoryTagPlaceholder": "選擇出站",
|
|
||||||
"xrayObservatoryAlive": "在線",
|
|
||||||
"xrayObservatoryDead": "離線",
|
|
||||||
"xrayObservatoryLastSeen": "最後在線",
|
|
||||||
"xrayObservatoryLastTry": "最後嘗試",
|
|
||||||
"connectionCount": "連線數",
|
"connectionCount": "連線數",
|
||||||
"ipAddresses": "IP 地址",
|
"ipAddresses": "IP 地址",
|
||||||
"toggleIpVisibility": "切換 IP 可見性",
|
"toggleIpVisibility": "切換 IP 可見性",
|
||||||
@@ -191,18 +152,6 @@
|
|||||||
"panelUpdateUnknownTitle": "無法確認更新是否已完成",
|
"panelUpdateUnknownTitle": "無法確認更新是否已完成",
|
||||||
"panelUpdateUnknownDesc": "面板未能及時回報結果。請重新整理以檢查目前版本,或檢查伺服器日誌。",
|
"panelUpdateUnknownDesc": "面板未能及時回報結果。請重新整理以檢查目前版本,或檢查伺服器日誌。",
|
||||||
"dontRefresh": "安裝中,請勿重新整理此頁面",
|
"dontRefresh": "安裝中,請勿重新整理此頁面",
|
||||||
"logs": "記錄",
|
|
||||||
"accessLogs": "存取記錄",
|
|
||||||
"autoUpdate": "自動更新",
|
|
||||||
"amneziawgLogs": "AmneziaWG 日誌",
|
|
||||||
"amneziawgHandshake": "最後握手",
|
|
||||||
"amneziawgInterface": "網路介面",
|
|
||||||
"amneziawgInbound": "入站",
|
|
||||||
"amneziawgEndpoint": "端點",
|
|
||||||
"amneziawgIdle": "閒置",
|
|
||||||
"amneziawgEvents": "事件",
|
|
||||||
"amneziawgNoPeers": "目前沒有活躍的 AmneziaWG 對端",
|
|
||||||
"amneziawgNoEvents": "尚未記錄 AmneziaWG 事件",
|
|
||||||
"config": "配置",
|
"config": "配置",
|
||||||
"backupTitle": "備份和恢復",
|
"backupTitle": "備份和恢復",
|
||||||
"exportDatabase": "備份",
|
"exportDatabase": "備份",
|
||||||
@@ -219,25 +168,14 @@
|
|||||||
"importDatabasePgDesc": "點擊選擇並上傳 PostgreSQL 備份(.dump)、SQLite 資料庫(.db)或 SQLite 遷移匯出檔以還原您的資料庫。此操作將取代所有目前的資料。",
|
"importDatabasePgDesc": "點擊選擇並上傳 PostgreSQL 備份(.dump)、SQLite 資料庫(.db)或 SQLite 遷移匯出檔以還原您的資料庫。此操作將取代所有目前的資料。",
|
||||||
"migrationDownload": "下載遷移檔案",
|
"migrationDownload": "下載遷移檔案",
|
||||||
"migrationDownloadPgDesc": "點擊下載由 PostgreSQL 資料建立的 .db SQLite 資料庫,可用於在 SQLite 上執行本面板。",
|
"migrationDownloadPgDesc": "點擊下載由 PostgreSQL 資料建立的 .db SQLite 資料庫,可用於在 SQLite 上執行本面板。",
|
||||||
"avg": "均值",
|
|
||||||
"peak": "峰值",
|
|
||||||
"free": "剩餘",
|
"free": "剩餘",
|
||||||
"openSockets": "開啟的通訊端",
|
"openSockets": "開啟的通訊端",
|
||||||
"throughputSub": "網路介面總計",
|
"throughputSub": "網路介面總計",
|
||||||
"avgWindow": "視窗均值",
|
|
||||||
"healthWarm": "{list} — 偏高",
|
"healthWarm": "{list} — 偏高",
|
||||||
"healthCritical": "{list} — 危險",
|
"healthCritical": "{list} — 危險",
|
||||||
"panel": "面板",
|
"panel": "面板",
|
||||||
"threads": "執行緒",
|
"threads": "執行緒",
|
||||||
"uptime": "執行時間",
|
"uptime": "執行時間",
|
||||||
"logLevelDebug": "Debug",
|
|
||||||
"logLevelInfo": "Info",
|
|
||||||
"logLevelNotice": "Notice",
|
|
||||||
"logLevelWarning": "Warning",
|
|
||||||
"logLevelError": "Error",
|
|
||||||
"accessDirect": "DIRECT",
|
|
||||||
"accessBlocked": "BLOCKED",
|
|
||||||
"accessProxy": "PROXY",
|
|
||||||
"importKeepHostSettings": "保留本機設定",
|
"importKeepHostSettings": "保留本機設定",
|
||||||
"importKeepHostSettingsDesc": "保留本面板的監聽位址、連接埠、基礎路徑、憑證與節點身分,而不是使用上傳檔案中的值。"
|
"importKeepHostSettingsDesc": "保留本面板的監聽位址、連接埠、基礎路徑、憑證與節點身分,而不是使用上傳檔案中的值。"
|
||||||
},
|
},
|
||||||
@@ -970,7 +908,19 @@
|
|||||||
"pinnedCertPlaceholder": "base64 或 hex 的 SHA-256",
|
"pinnedCertPlaceholder": "base64 或 hex 的 SHA-256",
|
||||||
"fetchPin": "取得",
|
"fetchPin": "取得",
|
||||||
"pinFetched": "已取得節點目前憑證",
|
"pinFetched": "已取得節點目前憑證",
|
||||||
"pinFetchFailed": "無法取得憑證"
|
"pinFetchFailed": "無法取得憑證",
|
||||||
|
"nodeType": "Node Type",
|
||||||
|
"nodeTypeTraditional": "Traditional Panel Node",
|
||||||
|
"nodeTypeAgent": "Agent Lightweight Node",
|
||||||
|
"agentHint": "Agent nodes connect out to the master; no address, port or API token needed. An install command is generated on creation.",
|
||||||
|
"agentTokenTitle": "Agent Node Setup",
|
||||||
|
"agentTokenCreated": "Node {{name}} created successfully",
|
||||||
|
"agentTokenHint": "Copy the command below and run it on the node machine. This token is shown only once!",
|
||||||
|
"agentTokenInstructions": "The command downloads the installer, configures the master URL and token. The node comes online in ~30s.",
|
||||||
|
"agentTokenCopied": "Install command copied",
|
||||||
|
"agentTokenCopyFailed": "Copy failed, please copy manually",
|
||||||
|
"copyToken": "Copy Install Command",
|
||||||
|
"agentMasterUrlHint": "Master URL (used by agent to reach the master; change to public address if behind proxy):"
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"defaultTag": "預設",
|
"defaultTag": "預設",
|
||||||
@@ -1725,4 +1675,4 @@
|
|||||||
"noIpRecord": "❗ 沒有 IP 記錄!"
|
"noIpRecord": "❗ 沒有 IP 記錄!"
|
||||||
},
|
},
|
||||||
"email": {}
|
"email": {}
|
||||||
}
|
}
|
||||||
@@ -491,7 +491,6 @@ func (s *Server) start(restartXray bool) (err error) {
|
|||||||
|
|
||||||
// Create event bus before startTask so jobs can use it
|
// Create event bus before startTask so jobs can use it
|
||||||
s.bus = eventbus.New(eventbus.DefaultBufferSize)
|
s.bus = eventbus.New(eventbus.DefaultBufferSize)
|
||||||
service.SetEventBus(s.bus)
|
|
||||||
job.EventBus = s.bus
|
job.EventBus = s.bus
|
||||||
|
|
||||||
// Wire xray crash callback BEFORE startTask so it's ready
|
// Wire xray crash callback BEFORE startTask so it's ready
|
||||||
@@ -531,9 +530,6 @@ func (s *Server) stop(stopXray bool) error {
|
|||||||
if s.bus != nil {
|
if s.bus != nil {
|
||||||
s.bus.Stop()
|
s.bus.Stop()
|
||||||
}
|
}
|
||||||
if err := service.PersistSystemMetrics(); err != nil {
|
|
||||||
logger.Warning("persist system metrics on shutdown failed:", err)
|
|
||||||
}
|
|
||||||
if stopXray {
|
if stopXray {
|
||||||
service.StopTrafficWriter()
|
service.StopTrafficWriter()
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user