1. 修改注册探针的服务端地址为手动填写

2. 优化 Linux 系统服务模板
3. 优化首页流量展示方式
This commit is contained in:
dushixiang committed 2026-02-10 19:56:46 +08:00
1 parent 31619096b8
commit d40fc1314f
10 files changed
+336 -238

No files matched your search

+13 -16
View File
@@ -57,21 +57,15 @@ func (h *AgentHandler) DownloadAgent(c echo.Context) error {
return c.Stream(http.StatusOK, "application/octet-stream", agentFile)
}
// getServerURL 获取服务器地址(支持反向代理)
func getServerURL(c echo.Context) string {
// 优先读取 X-Forwarded-Proto 和 X-Forwarded-Host
scheme := c.Request().Header.Get("X-Forwarded-Proto")
host := c.Request().Header.Get("X-Forwarded-Host")
// 如果没有反向代理头部,使用默认值
if scheme == "" {
scheme = c.Scheme()
}
if host == "" {
host = c.Request().Host
// getServerURL 获取服务器地址(仅从配置读取)
func (h *AgentHandler) getServerURL(c echo.Context) string {
// 优先读取配置的服务端地址
config, err := h.propertyService.GetAgentInstallConfig(c.Request().Context())
if err == nil && config.ServerURL != "" {
return config.ServerURL
}
return scheme + "://" + host
return ""
}
func bashSingleQuote(value string) string {
@@ -82,7 +76,7 @@ func bashSingleQuote(value string) string {
}
func (h *AgentHandler) GetServerUrl(c echo.Context) error {
serverUrl := getServerURL(c)
serverUrl := h.getServerURL(c)
return orz.Ok(c, orz.Map{
"serverUrl": serverUrl,
})
@@ -95,8 +89,11 @@ func (h *AgentHandler) GetInstallScript(c echo.Context) error {
return orz.NewError(400, "token不能为空")
}
// 使用统一的 getServerURL 函数获取服务器地址(支持反向代理)
serverUrl := getServerURL(c)
// 使用统一的 getServerURL 函数获取服务器地址(仅从配置读取)
serverUrl := strings.TrimSpace(h.getServerURL(c))
if serverUrl == "" {
return orz.NewError(400, "请先配置服务端地址")
}
customName := strings.TrimSpace(c.QueryParam("name"))
customNameLiteral := bashSingleQuote(customName)
+5
View File
@@ -156,3 +156,8 @@ type AlertNotifications struct {
SSHLoginSuccessEnabled bool `json:"sshLoginSuccessEnabled"` // SSH 登录成功通知
TamperEventEnabled bool `json:"tamperEventEnabled"` // 防篡改事件通知
}
// AgentInstallConfig 探针安装配置
type AgentInstallConfig struct {
ServerURL string `json:"serverUrl"` // 服务端地址
}
+22
View File
@@ -28,6 +28,8 @@ const (
PropertyIDAlertConfig = "alert_config"
// PropertyIDDNSProviders DNS 服务商配置的固定 ID
PropertyIDDNSProviders = "dns_providers"
// PropertyIDAgentInstallConfig 探针安装配置的固定 ID
PropertyIDAgentInstallConfig = "agent_install_config"
)
var defaultPublicIPv4APIs = []string{
@@ -313,6 +315,21 @@ func (s *PropertyService) DeleteDNSProvider(ctx context.Context, providerType st
return s.SetDNSProviderConfigs(ctx, newProviders)
}
// GetAgentInstallConfig 获取探针安装配置
func (s *PropertyService) GetAgentInstallConfig(ctx context.Context) (*models.AgentInstallConfig, error) {
var config models.AgentInstallConfig
err := s.GetValue(ctx, PropertyIDAgentInstallConfig, &config)
if err != nil {
return nil, fmt.Errorf("获取探针安装配置失败: %w", err)
}
return &config, nil
}
// SetAgentInstallConfig 设置探针安装配置
func (s *PropertyService) SetAgentInstallConfig(ctx context.Context, config models.AgentInstallConfig) error {
return s.Set(ctx, PropertyIDAgentInstallConfig, "探针安装配置", config)
}
// defaultPropertyConfig 默认配置项定义
type defaultPropertyConfig struct {
ID string
@@ -393,6 +410,11 @@ func (s *PropertyService) InitializeDefaultConfigs(ctx context.Context) error {
Name: "DNS 服务商配置",
Value: []models.DNSProviderConfig{}, // 默认为空数组
},
{
ID: PropertyIDAgentInstallConfig,
Name: "探针安装配置",
Value: models.AgentInstallConfig{ServerURL: ""}, // 默认空字符串,使用自动检测
},
}
// 遍历并初始化每个配置
+13 -6
View File
@@ -111,7 +111,8 @@ type ServiceManager struct {
const systemdScript = `[Unit]
Description={{.Description}}
ConditionFileIsExecutable={{.Path|cmdEscape}}
{{range .Dependencies}} {{.}} {{end}}
{{range $i, $dep := .Dependencies}}
{{$dep}} {{end}}
[Service]
StartLimitInterval=5
@@ -122,14 +123,20 @@ ExecStart={{.Path|cmdEscape}}{{range .Arguments}} {{.|cmd}}{{end}}
{{if .UserName}}User={{.UserName}}{{end}}
{{if .ReloadSignal}}ExecReload=/bin/kill -{{.ReloadSignal}} "$MAINPID"{{end}}
{{if .PIDFile}}PIDFile={{.PIDFile|cmd}}{{end}}
{{if .LogOutput}}StandardOutput={{.LogOutput}}
StandardError={{.LogOutput}}{{end}}
{{if .LogDirectory}}LogsDirectory={{.LogDirectory}}{{end}}
Restart=always
{{if and .LogOutput .HasOutputFileSupport -}}
StandardOutput=file:{{.LogDirectory}}/{{.Name}}.out
StandardError=file:{{.LogDirectory}}/{{.Name}}.err
{{- end}}
{{if gt .LimitNOFILE -1 }}LimitNOFILE={{.LimitNOFILE}}{{end}}
{{if .Restart}}Restart={{.Restart}}{{end}}
{{if .SuccessExitStatus}}SuccessExitStatus={{.SuccessExitStatus}}{{end}}
RestartSec=5
KillMode=process
EnvironmentFile=-/etc/sysconfig/{{.Name}}
{{range $k, $v := .EnvVars -}}
Environment={{$k}}={{$v}}
{{end -}}
[Install]
WantedBy=multi-user.target
`
@@ -9,7 +9,6 @@ import {
AgentInstallLayout,
ApiChooser,
ConfigHelper,
ServerUrlChecker,
ServiceHelper,
AGENT_NAME,
AGENT_NAME_EXE,
@@ -36,14 +35,11 @@ const DEFAULT_OS: OSType = 'linux-amd64';
const AgentInstallManual = () => {
const { message } = App.useApp();
const frontendUrl = useMemo(() => window.location.origin, []);
const [selectedOS, setSelectedOS] = useState<OSType>(DEFAULT_OS);
const {
apiKeys,
selectedApiKey,
setSelectedApiKey,
customAgentName,
setCustomAgentName,
loading,
backendServerUrl,
apiKeyOptions,
@@ -94,7 +90,6 @@ const AgentInstallManual = () => {
const getManualInstallSteps = (os: OSType): InstallStep[] => {
const config = osConfigs[os];
const trimmedName = customAgentName.trim();
if (os.startsWith('windows')) {
return [
@@ -108,7 +103,7 @@ Invoke-WebRequest -Uri "${backendServerUrl}${config.downloadUrl}?key=${selectedA
},
{
title: '2. 注册探针',
command: `.\\${AGENT_NAME_EXE} register --endpoint "${backendServerUrl}" --token "${selectedApiKey}"${trimmedName ? ` --name "${trimmedName}"` : ''}`
command: `.\\${AGENT_NAME_EXE} register --endpoint "${backendServerUrl}" --token "${selectedApiKey}"`
},
{
title: '3. 验证安装',
@@ -136,7 +131,7 @@ curl -L "${backendServerUrl}${config.downloadUrl}?key=${selectedApiKey}" -o ${AG
},
{
title: '4. 注册探针',
command: `sudo ${AGENT_NAME} register --endpoint "${backendServerUrl}" --token "${selectedApiKey}"${trimmedName ? ` --name "${trimmedName}"` : ''}`
command: `sudo ${AGENT_NAME} register --endpoint "${backendServerUrl}" --token "${selectedApiKey}"`
},
{
title: '5. 验证安装',
@@ -148,15 +143,12 @@ curl -L "${backendServerUrl}${config.downloadUrl}?key=${selectedApiKey}" -o ${AG
return (
<AgentInstallLayout activeKey="manual">
<Space direction="vertical" className="w-full">
<ServerUrlChecker backendServerUrl={backendServerUrl} frontendUrl={frontendUrl} />
<ApiChooser
apiKeys={apiKeys}
selectedApiKey={selectedApiKey}
apiKeyOptions={apiKeyOptions}
loading={loading}
customAgentName={customAgentName}
onSelectApiKey={setSelectedApiKey}
onCustomNameBlur={setCustomAgentName}
/>
<Tabs
activeKey={selectedOS}
@@ -1,22 +1,20 @@
import React, { useMemo } from 'react';
import { Alert, App, Button, Card, Space, Typography } from 'antd';
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, App, Button, Card, Input, Select, Space, Spin, Typography } from 'antd';
import { CopyIcon } from 'lucide-react';
import copy from 'copy-to-clipboard';
import {
AgentInstallLayout,
ApiChooser,
ConfigHelper,
ServerUrlChecker,
ServiceHelper,
AGENT_NAME,
} from './AgentInstallShared';
import { useAgentInstallConfig } from './useAgentInstallConfig';
import { getAgentInstallConfig, saveAgentInstallConfig } from '@/api/property';
const { Paragraph } = Typography;
const AgentInstallOneClick = () => {
const { message } = App.useApp();
const frontendUrl = useMemo(() => window.location.origin, []);
const {
apiKeys,
selectedApiKey,
@@ -28,14 +26,64 @@ const AgentInstallOneClick = () => {
apiKeyOptions,
} = useAgentInstallConfig();
const [serverUrl, setServerUrl] = useState<string>('');
const [serverUrlError, setServerUrlError] = useState<string>('');
const [serverUrlLoading, setServerUrlLoading] = useState(true);
const effectiveServerUrl = serverUrl || backendServerUrl;
// 加载服务端地址配置
useEffect(() => {
const fetchConfig = async () => {
try {
const config = await getAgentInstallConfig();
const configuredUrl = (config.serverUrl || '').trim();
setServerUrl(configuredUrl);
setServerUrlError(configuredUrl ? '' : '请先配置服务端地址');
} catch (error) {
console.error('加载服务端地址配置失败:', error);
} finally {
setServerUrlLoading(false);
}
};
void fetchConfig();
}, []);
// 保存服务端地址配置
const handleServerUrlBlur = async (value: string) => {
const trimmed = value.trim();
setServerUrl(trimmed);
if (!trimmed) {
setServerUrlError('请先配置服务端地址');
message.error('请先配置服务端地址');
return;
}
setServerUrlError('');
try {
await saveAgentInstallConfig({ serverUrl: trimmed });
message.success('服务端地址已保存');
} catch (error) {
console.error('保存服务端地址失败:', error);
message.error('保存服务端地址失败');
}
};
const handleFillCurrentUrl = () => {
const currentUrl = window.location.origin;
setServerUrl(currentUrl);
setServerUrlError('');
void handleServerUrlBlur(currentUrl);
};
const installCommand = useMemo(() => {
if (!backendServerUrl || !selectedApiKey) {
if (!effectiveServerUrl || !selectedApiKey) {
return '';
}
const trimmedName = customAgentName.trim();
const nameParam = trimmedName ? `&name=${encodeURIComponent(trimmedName)}` : '';
return `curl -fsSL "${backendServerUrl}/api/agent/install.sh?token=${selectedApiKey}${nameParam}" | sudo bash`;
}, [backendServerUrl, selectedApiKey, customAgentName]);
return `curl -fsSL "${effectiveServerUrl}/api/agent/install.sh?token=${selectedApiKey}${nameParam}" | sudo bash`;
}, [backendServerUrl, selectedApiKey, customAgentName, serverUrl]);
const copyToClipboard = (text: string) => {
copy(text);
@@ -45,22 +93,97 @@ const AgentInstallOneClick = () => {
return (
<AgentInstallLayout activeKey="one-click">
<Space direction="vertical" className="w-full">
<ServerUrlChecker backendServerUrl={backendServerUrl} frontendUrl={frontendUrl} />
<ApiChooser
apiKeys={apiKeys}
selectedApiKey={selectedApiKey}
apiKeyOptions={apiKeyOptions}
loading={loading}
customAgentName={customAgentName}
onSelectApiKey={setSelectedApiKey}
onCustomNameBlur={setCustomAgentName}
/>
<Card type="inner" title="配置选项">
<Space direction="vertical" className="w-full">
<div>
<div className="mb-1 text-gray-600 dark:text-slate-400">
服务端地址 <span className="text-xs text-gray-400">(必填)</span>
</div>
<Spin spinning={serverUrlLoading}>
<Input
key={serverUrl}
placeholder="例如: https://monitor.example.com"
defaultValue={serverUrl}
onBlur={(e) => {
void handleServerUrlBlur(e.currentTarget.value);
}}
className="w-full"
/>
</Spin>
<div className="mt-2">
<Button size="small" onClick={handleFillCurrentUrl}>
使用当前访问地址
</Button>
</div>
{serverUrlError ? (
<div className="mt-1 text-xs text-red-500">{serverUrlError}</div>
) : (
<div className="mt-1 text-xs text-gray-400">
请配置可访问的服务端地址,否则无法生成安装命令
</div>
)}
</div>
<div>
<div className="mb-1 text-gray-600 dark:text-slate-400">选择 API Token</div>
{apiKeys.length === 0 ? (
<Alert
message="暂无可用的 API Token"
description={
<span>
请先前往 <a href="/admin/api-keys">API密钥管理</a> 页面生成一个 API Token
</span>
}
type="warning"
showIcon
className="mt-2"
/>
) : (
<Select
className="w-full"
value={selectedApiKey}
onChange={setSelectedApiKey}
options={apiKeyOptions}
loading={loading}
placeholder="请选择 API Token"
/>
)}
</div>
<div>
<div className="mb-1 text-gray-600 dark:text-slate-400">
自定义名称 <span className="text-xs text-gray-400">(可选,留空则使用主机名)</span>
</div>
<Input
key={customAgentName}
placeholder="请输入自定义名称,例如: my-server-01"
defaultValue={customAgentName}
onBlur={(e) => {
const trimmed = e.currentTarget.value.trim();
setCustomAgentName(trimmed);
e.currentTarget.value = trimmed;
}}
className="w-full"
allowClear
/>
</div>
</Space>
</Card>
<Alert
description="一键安装脚本仅支持 Linux/macOS 系统。"
type="info"
showIcon
className="mt-2"
/>
{!effectiveServerUrl && (
<Alert
description="请先配置服务端地址后再生成安装命令。"
type="warning"
showIcon
className="mt-2"
/>
)}
<Card type="inner" title="一键安装">
<Paragraph type="secondary" className="mb-3 text-gray-600 dark:text-slate-400">
脚本会自动检测系统架构并下载对应版本的探针,然后完成注册和安装。
@@ -74,7 +197,7 @@ const AgentInstallOneClick = () => {
onClick={() => void copyToClipboard(installCommand)}
icon={<CopyIcon className="h-4 w-4" />}
style={{ margin: 0, padding: 0 }}
disabled={!selectedApiKey}
disabled={!selectedApiKey || !effectiveServerUrl}
>
复制命令
</Button>
+24 -111
View File
@@ -21,9 +21,7 @@ export type ApiChooserProps = {
selectedApiKey: string;
apiKeyOptions: ApiKeyOption[];
loading: boolean;
customAgentName: string;
onSelectApiKey: (value: string) => void;
onCustomNameBlur: (value: string) => void;
};
export const AgentInstallLayout = ({ activeKey, children }: { activeKey: InstallNavKey; children: ReactNode }) => {
@@ -64,124 +62,39 @@ export const AgentInstallLayout = ({ activeKey, children }: { activeKey: Install
);
};
export const ServerUrlChecker = ({ backendServerUrl, frontendUrl }: { backendServerUrl: string; frontendUrl: string }) => {
const hasAddressMismatch = backendServerUrl && backendServerUrl !== frontendUrl;
if (!hasAddressMismatch) {
return null;
}
return (
<Alert
message="检测到地址不一致"
description={
<Space direction="vertical" className="w-full">
<div>
当前访问地址: <Text code>{frontendUrl}</Text>
<br />
后端检测地址: <Text code>{backendServerUrl}</Text>
</div>
<div>
<Text strong>这通常是因为您使用了反向代理,但未正确配置转发头部。</Text>
</div>
<div>
<Text>请在反向代理配置中添加以下头部:</Text>
</div>
<div>
<Text strong>Nginx 配置示例:</Text>
<pre className="m-0 mt-2 overflow-auto text-xs bg-gray-100 dark:bg-slate-900 p-2 rounded">
{`location / {
proxy_pass http://backend;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}`}
</pre>
</div>
<div>
<Text strong>Caddy 配置示例:</Text>
<pre className="m-0 mt-2 overflow-auto text-xs bg-gray-100 dark:bg-slate-900 p-2 rounded">
{`reverse_proxy backend:8080 {
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
}`}
</pre>
</div>
<div>
<Text strong>Traefik 配置说明:</Text>
<pre className="m-0 mt-2 overflow-auto text-xs bg-gray-100 dark:bg-slate-900 p-2 rounded">
{`# Traefik 默认会自动添加 X-Forwarded-* 头部
# 无需额外配置`}
</pre>
</div>
<div className="mt-2">
<Text type="secondary">配置完成后,刷新页面即可生效。</Text>
</div>
</Space>
}
type="warning"
showIcon
closable
/>
);
};
export const ApiChooser = ({
apiKeys,
selectedApiKey,
apiKeyOptions,
loading,
customAgentName,
onSelectApiKey,
onCustomNameBlur,
}: ApiChooserProps) => (
<Card type="inner" title="配置选项">
<Space direction="vertical" className="w-full">
<div>
<div className="mb-1 text-gray-600 dark:text-slate-400">选择 API Token</div>
{apiKeys.length === 0 ? (
<Alert
message="暂无可用的 API Token"
description={
<span>
请先前往 <a href="/admin/api-keys">API密钥管理</a> 页面生成一个 API Token
</span>
}
type="warning"
showIcon
className="mt-2"
/>
) : (
<Select
className="w-full"
value={selectedApiKey}
onChange={onSelectApiKey}
options={apiKeyOptions}
loading={loading}
placeholder="请选择 API Token"
/>
)}
</div>
<div>
<div className="mb-1 text-gray-600 dark:text-slate-400">
自定义名称 <span className="text-xs text-gray-400">(可选,留空则使用主机名)</span>
</div>
<Input
key={customAgentName}
placeholder="请输入自定义名称,例如: my-server-01"
defaultValue={customAgentName}
onBlur={(e) => {
const trimmed = e.currentTarget.value.trim();
onCustomNameBlur(trimmed);
e.currentTarget.value = trimmed;
}}
className="w-full"
allowClear
<div>
<div className="mb-1 text-gray-600 dark:text-slate-400">选择 API Token</div>
{apiKeys.length === 0 ? (
<Alert
message="暂无可用的 API Token"
description={
<span>
请先前往 <a href="/admin/api-keys">API密钥管理</a> 页面生成一个 API Token
</span>
}
type="warning"
showIcon
className="mt-2"
/>
</div>
</Space>
) : (
<Select
className="w-full"
value={selectedApiKey}
onChange={onSelectApiKey}
options={apiKeyOptions}
loading={loading}
placeholder="请选择 API Token"
/>
)}
</div>
</Card>
);
+19
View File
@@ -173,3 +173,22 @@ export const getAlertConfig = async (): Promise<AlertConfig> => {
export const saveAlertConfig = async (config: AlertConfig): Promise<void> => {
return saveProperty(PROPERTY_ID_ALERT_CONFIG, '告警配置', config);
};
// ==================== 探针安装配置 ====================
const PROPERTY_ID_AGENT_INSTALL_CONFIG = 'agent_install_config';
// 探针安装配置
export interface AgentInstallConfig {
serverUrl: string; // 服务端地址
}
// 获取探针安装配置
export const getAgentInstallConfig = async (): Promise<AgentInstallConfig> => {
return getProperty<AgentInstallConfig>(PROPERTY_ID_AGENT_INSTALL_CONFIG);
};
// 保存探针安装配置
export const saveAgentInstallConfig = async (config: AgentInstallConfig): Promise<void> => {
return saveProperty(PROPERTY_ID_AGENT_INSTALL_CONFIG, '探针安装配置', config);
};
+45
View File
@@ -53,6 +53,13 @@ const getTemperatures = (metrics?: LatestMetrics) => {
return metrics.temperature.sort((a, b) => a.type.localeCompare(b.type));
};
const getTrafficProgressColor = (percent: number) => {
if (percent >= 100) return 'bg-red-500';
if (percent >= 90) return 'bg-orange-500';
if (percent >= 80) return 'bg-yellow-500';
return 'bg-emerald-500';
};
const ServerCard: FC<ServerCardProps> = ({server}) => {
const isOnline = server.status === 1;
const cpuUsage = server.metrics?.cpu?.usagePercent ?? 0;
@@ -65,6 +72,10 @@ const ServerCard: FC<ServerCardProps> = ({server}) => {
const {upload, download} = calculateNetworkSpeed(server.metrics);
const temperatures = getTemperatures(server.metrics);
const netConn = server.metrics?.networkConnection;
const traffic = server.trafficStats;
const trafficUsagePercent = traffic?.enabled && traffic.limit > 0
? Math.min(100, (traffic.used / traffic.limit) * 100)
: 0;
return (
<Link to={`/servers/${server.id}`}>
@@ -210,6 +221,40 @@ const ServerCard: FC<ServerCardProps> = ({server}) => {
</span>
</div>
)}
{traffic?.enabled && (
<div className="pt-2 border-t border-slate-200 dark:border-cyan-900/30 space-y-1.5">
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-cyan-600 font-mono">
<Activity className="w-3 h-3"/>
<span>{traffic.type === 'recv' ? '进站' : traffic.type === 'send' ? '出站' : '全部'}流量</span>
</div>
{traffic.limit > 0 ? (
<>
<div className="flex items-baseline justify-between">
<span className="text-xs text-gray-600 dark:text-cyan-500 font-mono">
{formatBytes(traffic.used, 1)} / {formatBytes(traffic.limit, 1)}
</span>
<span className="text-xs font-bold text-gray-700 dark:text-cyan-400 font-mono">
{trafficUsagePercent.toFixed(1)}%
</span>
</div>
<div className="h-1.5 bg-slate-200 dark:bg-cyan-900/50 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${getTrafficProgressColor(trafficUsagePercent)}`}
style={{width: `${trafficUsagePercent}%`}}
/>
</div>
<div className="text-xs text-gray-500 dark:text-cyan-600 font-mono">
重置日期: 每月 {traffic.resetDay} 号
</div>
</>
) : (
<div className="text-xs text-gray-500 dark:text-cyan-600 font-mono">
已使用: {formatBytes(traffic.used, 1)}
<div className="text-xs text-gray-400 dark:text-cyan-700 mt-1">仅统计模式</div>
</div>
)}
</div>
)}
</div>
</div>
</CyberCard>
+51 -76
View File
@@ -75,6 +75,13 @@ const getTemperatures = (metrics?: LatestMetrics) => {
return metrics.temperature.sort((a, b) => a.type.localeCompare(b.type));
};
const getTrafficProgressColor = (percent: number) => {
if (percent >= 100) return 'bg-red-500';
if (percent >= 90) return 'bg-orange-500';
if (percent >= 80) return 'bg-yellow-500';
return 'bg-emerald-500';
};
const ServerList = () => {
const navigate = useNavigate();
const [selectedTag, setSelectedTag] = useState<string>('');
@@ -193,82 +200,6 @@ const ServerList = () => {
/>
</div>
{/* 流量统计卡片 */}
{agents.filter(a => a.trafficStats?.enabled).length > 0 && (
<div className="bg-white/80 dark:bg-[#0a0b10]/90 border border-slate-200 dark:border-cyan-900/50 rounded-xl overflow-hidden shadow-sm dark:shadow-2xl backdrop-blur-md p-4">
<div className="flex items-center gap-2 mb-4 text-slate-800 dark:text-cyan-100 font-mono font-bold">
<Activity className="w-4 h-4"/>
<span className="text-sm uppercase tracking-wider">流量统计</span>
<span className="text-xs text-gray-500 dark:text-cyan-500">({agents.filter(a => a.trafficStats?.enabled).length} 台设备已启用)</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{agents.filter(a => a.trafficStats?.enabled).map(agent => {
const traffic = agent.trafficStats!;
const usagePercent = traffic.limit > 0 ? Math.min(100, (traffic.used / traffic.limit) * 100) : 0;
const getProgressColor = (percent: number) => {
if (percent >= 100) return 'bg-red-500';
if (percent >= 90) return 'bg-orange-500';
if (percent >= 80) return 'bg-yellow-500';
return 'bg-emerald-500';
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
};
return (
<div
key={agent.id}
onClick={() => handleNavigate(agent.id)}
className="bg-white dark:bg-black/40 border border-slate-200 dark:border-cyan-900/30 rounded-lg p-3 hover:border-cyan-500 dark:hover:border-cyan-500 transition-all cursor-pointer group"
>
<div className="flex items-start justify-between mb-2">
<div className="flex-1 min-w-0">
<div className="font-mono font-bold text-sm text-slate-800 dark:text-cyan-100 truncate group-hover:text-cyan-600 dark:group-hover:text-cyan-400 transition-colors">
{agent.name}
</div>
<div className="text-xs text-gray-500 dark:text-cyan-600 font-mono mt-0.5">
{traffic.type === 'recv' ? '进站' : traffic.type === 'send' ? '出站' : '全部'}流量
</div>
</div>
</div>
{traffic.limit > 0 ? (
<>
<div className="flex items-baseline justify-between mb-1">
<span className="text-xs text-gray-600 dark:text-cyan-500 font-mono">
{formatBytes(traffic.used)} / {formatBytes(traffic.limit)}
</span>
<span className="text-xs font-bold text-gray-700 dark:text-cyan-400 font-mono">
{usagePercent.toFixed(1)}%
</span>
</div>
<div className="h-1.5 bg-slate-200 dark:bg-cyan-900/50 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${getProgressColor(usagePercent)}`}
style={{width: `${usagePercent}%`}}
/>
</div>
<div className="text-xs text-gray-500 dark:text-cyan-600 font-mono mt-1">
重置日期: 每月 {traffic.resetDay} 号
</div>
</>
) : (
<div className="text-xs text-gray-500 dark:text-cyan-600 font-mono">
已使用: {formatBytes(traffic.used)}
<div className="text-xs text-gray-400 dark:text-cyan-700 mt-1">仅统计模式</div>
</div>
)}
</div>
);
})}
</div>
</div>
)}
{/* 标签过滤器 */}
{allTags.length > 1 && (
<div className="flex flex-wrap gap-1.5 sm:gap-2 items-center">
@@ -322,6 +253,7 @@ const ServerList = () => {
<th className="p-5 font-bold w-[250px]">Identity</th>
<th className="p-5 font-bold">Telemetry</th>
<th className="p-5 font-bold w-[220px]">I/O Rate</th>
<th className="p-5 font-bold w-[240px]">Traffic</th>
<th className="p-5 font-bold w-[150px]">Network</th>
<th className="p-5 font-bold w-[200px]">Meta / Tags</th>
</tr>
@@ -339,6 +271,10 @@ const ServerList = () => {
const {upload, download} = calculateNetworkSpeed(server.metrics);
const temperatures = getTemperatures(server.metrics);
const netConn = server.metrics?.networkConnection;
const traffic = server.trafficStats;
const trafficUsagePercent = traffic?.enabled && traffic.limit > 0
? Math.min(100, (traffic.used / traffic.limit) * 100)
: 0;
return (
<tr
@@ -455,6 +391,45 @@ const ServerList = () => {
</div>
</td>
{/* Traffic */}
<td className="p-4 font-mono text-xs align-top">
{traffic?.enabled ? (
<div className="flex flex-col gap-1.5">
<div className="text-xs text-gray-500 dark:text-cyan-600 font-mono">
{traffic.type === 'recv' ? '进站' : traffic.type === 'send' ? '出站' : '全部'}流量
</div>
{traffic.limit > 0 ? (
<>
<div className="flex items-baseline justify-between">
<span className="text-xs text-gray-600 dark:text-cyan-500 font-mono">
{formatBytes(traffic.used, 1)} / {formatBytes(traffic.limit, 1)}
</span>
<span className="text-xs font-bold text-gray-700 dark:text-cyan-400 font-mono">
{trafficUsagePercent.toFixed(1)}%
</span>
</div>
<div className="h-1.5 bg-slate-200 dark:bg-cyan-900/50 rounded-full overflow-hidden">
<div
className={`h-full transition-all ${getTrafficProgressColor(trafficUsagePercent)}`}
style={{width: `${trafficUsagePercent}%`}}
/>
</div>
<div className="text-xs text-gray-500 dark:text-cyan-600 font-mono">
重置日期: 每月 {traffic.resetDay} 号
</div>
</>
) : (
<div className="text-xs text-gray-500 dark:text-cyan-600 font-mono">
已使用: {formatBytes(traffic.used, 1)}
<div className="text-xs text-gray-400 dark:text-cyan-700 mt-1">仅统计模式</div>
</div>
)}
</div>
) : (
<div className="text-gray-600 dark:text-cyan-500">-</div>
)}
</td>
{/* Connections */}
<td className="p-4 font-mono text-xs align-top">
{isOnline && netConn ? (