diff --git a/backend/src/api/agents.ts b/backend/src/api/agents.ts
index 991e5c7..64c5145 100644
--- a/backend/src/api/agents.ts
+++ b/backend/src/api/agents.ts
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { Bindings } from "../models/db";
-import { Agent } from "../models/agent";
+import { Agent, Metrics } from "../models/agent";
import {
getAgents,
getAgentDetail,
@@ -11,6 +11,7 @@ import {
registerAgentService,
updateAgentStatusService,
getAgentMetrics,
+ getLatestAgentMetrics,
} from "../services/AgentService";
const agents = new Hono<{
@@ -55,11 +56,7 @@ agents.put("/:id", async (c) => {
const payload = c.get("jwtPayload");
const updateData = await c.req.json();
- const result = await updateAgentService(
- c.env.DB,
- agentId,
- updateData
- );
+ const result = await updateAgentService(c.env.DB, agentId, updateData);
return c.json(
{
@@ -76,10 +73,7 @@ agents.delete("/:id", async (c) => {
const agentId = Number(c.req.param("id"));
const payload = c.get("jwtPayload");
- const result = await deleteAgentService(
- c.env.DB,
- agentId
- );
+ const result = await deleteAgentService(c.env.DB, agentId);
return c.json(
{
@@ -165,8 +159,22 @@ agents.get("/:id/metrics", async (c) => {
message: "获取客户端指标成功",
},
200
- )
-})
+ );
+});
+
+// 获取单个客户端的最新指标
+agents.get("/:id/metrics/latest", async (c) => {
+ const agentId = Number(c.req.param("id"));
+ const result = await getLatestAgentMetrics(c.env.DB, agentId);
+ return c.json(
+ {
+ success: result.success,
+ agent: result.results,
+ message: "获取客户端最新指标成功",
+ },
+ 200
+ );
+});
// 获取单个客户端
agents.get("/:id", async (c) => {
@@ -184,5 +192,4 @@ agents.get("/:id", async (c) => {
);
});
-
export { agents };
diff --git a/backend/src/middlewares/auth.ts b/backend/src/middlewares/auth.ts
index 45f63e8..27a15b6 100644
--- a/backend/src/middlewares/auth.ts
+++ b/backend/src/middlewares/auth.ts
@@ -20,7 +20,11 @@ export const jwtMiddleware = async (c: Context, next: Next) => {
return next();
}
// 获取 metrics 时暂时先不验证。
- if (c.req.path.endsWith("/metrics") && c.req.method === "GET") {
+ if (
+ (c.req.path.endsWith("/metrics") ||
+ c.req.path.endsWith("/metrics/latest")) &&
+ c.req.method === "GET"
+ ) {
return next();
}
const middleware = jwt({
diff --git a/backend/src/repositories/agent.ts b/backend/src/repositories/agent.ts
index bc19ff7..b93fb20 100644
--- a/backend/src/repositories/agent.ts
+++ b/backend/src/repositories/agent.ts
@@ -227,3 +227,11 @@ export async function getAgentMetrics(db: Bindings["DB"], agentId: number) {
.bind(agentId)
.all();
}
+
+// 获取指定客户端的最新指标
+export async function getLatestAgentMetrics(db: Bindings["DB"], agentId: number) {
+ return await db
+ .prepare("SELECT * FROM agent_metrics_24h WHERE agent_id =? ORDER BY timestamp DESC LIMIT 1")
+ .bind(agentId)
+ .all();
+}
diff --git a/backend/src/services/AgentService.ts b/backend/src/services/AgentService.ts
index faf5a96..8e3e8f7 100644
--- a/backend/src/services/AgentService.ts
+++ b/backend/src/services/AgentService.ts
@@ -475,4 +475,8 @@ export async function setAgentInactive(env: any, id: number) {
export async function getAgentMetrics(db: Bindings["DB"], agentId: number) {
return await AgentRepository.getAgentMetrics(db, agentId);
+}
+
+export async function getLatestAgentMetrics(db: Bindings["DB"], agentId: number) {
+ return await AgentRepository.getLatestAgentMetrics(db, agentId);
}
\ No newline at end of file
diff --git a/frontend/src/api/agents.ts b/frontend/src/api/agents.ts
index 5a07feb..4b89414 100644
--- a/frontend/src/api/agents.ts
+++ b/frontend/src/api/agents.ts
@@ -87,3 +87,14 @@ export const getAgentMetrics = async (
};
}
};
+
+export const getLatestAgentMetrics = async (
+ id: number
+): Promise<{
+ success: boolean;
+ agent?: MetricHistory;
+ message?: string;
+}> => {
+ const response = await api.get(`/api/agents/${id}/metrics/latest`);
+ return response.data;
+};
diff --git a/frontend/src/components/AgentCard.tsx b/frontend/src/components/AgentCard.tsx
index bdc2d98..6b0dc43 100644
--- a/frontend/src/components/AgentCard.tsx
+++ b/frontend/src/components/AgentCard.tsx
@@ -1,5 +1,5 @@
import { Box, Card, Flex, Text } from "@radix-ui/themes";
-import { Tabs, TabsContent, TabsList, TabsTrigger,Badge } from "./ui";
+import { Tabs, TabsContent, TabsList, TabsTrigger, Badge } from "./ui";
import { GlobeIcon } from "@radix-ui/react-icons";
import MetricsChart from "./MetricsChart";
import { useTranslation } from "react-i18next";
diff --git a/frontend/src/components/AgentStatusBar.tsx b/frontend/src/components/AgentStatusBar.tsx
new file mode 100644
index 0000000..9015265
--- /dev/null
+++ b/frontend/src/components/AgentStatusBar.tsx
@@ -0,0 +1,216 @@
+import React from "react";
+import { MetricHistory, AgentWithLatestMetrics } from "../types";
+import { Badge } from "./ui/badge";
+import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
+import {
+ faWindows,
+ faLinux,
+ faApple,
+ faAndroid,
+} from "@fortawesome/free-brands-svg-icons";
+import { faLaptop } from "@fortawesome/free-solid-svg-icons";
+
+interface AgentStatusBarProps {
+ latestMetric?: MetricHistory;
+ agent: AgentWithLatestMetrics;
+}
+
+interface DiskMetric {
+ device: string;
+ mount_point: string;
+ total: number;
+ used: number;
+ free: number;
+ usage_rate: number;
+ fs_type: string;
+}
+
+interface NetworkMetric {
+ interface: string;
+ bytes_sent: number;
+ bytes_recv: number;
+ packets_sent: number;
+ packets_recv: number;
+}
+
+const formatBytes = (bytes: number | undefined, decimals = 2): string => {
+ if (bytes === undefined || bytes === 0) return "0 B";
+ const k = 1024;
+ const dm = decimals < 0 ? 0 : decimals;
+ const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
+};
+
+const formatPercent = (val: number | undefined, decimals = 2) =>
+ val !== undefined ? `${val.toFixed(decimals)}%` : "-";
+
+const ProgressBar = ({
+ percent,
+ color,
+}: {
+ percent: number;
+ color: string;
+}) => (
+
+);
+
+const MetricCard = ({
+ label,
+ value,
+ subValue,
+ percent,
+ color,
+}: {
+ label: string;
+ value: string;
+ subValue?: string;
+ percent?: number;
+ color?: string;
+}) => (
+
+
{label}
+
{value}
+ {subValue &&
{subValue}}
+ {percent !== undefined && color && (
+
+ )}
+
+);
+
+const getOsIcon = (os: string | undefined) => {
+ if (!os) return faLaptop;
+ const osLower = os.toLowerCase();
+ if (osLower.includes("windows")) return faWindows;
+ if (osLower.includes("mac") || osLower.includes("darwin")) return faApple;
+ if (osLower.includes("linux")) return faLinux;
+ if (osLower.includes("android")) return faAndroid;
+ if (osLower.includes("ios")) return faApple;
+ return faLaptop;
+};
+
+const AgentStatusBar: React.FC = ({
+ latestMetric,
+ agent,
+}) => {
+ // 计算存储总量和使用情况
+ let totalStorage = 0;
+ let usedStorage = 0;
+ let storageUsageRate = 0;
+
+ // 计算网络总量
+ let totalUpload = 0;
+ let totalDownload = 0;
+
+ if (latestMetric) {
+ // 解析并聚合所有磁盘数据
+ if (latestMetric.disk_metrics) {
+ try {
+ const disks = JSON.parse(latestMetric.disk_metrics) as DiskMetric[];
+ disks.forEach((disk) => {
+ totalStorage += disk.total;
+ usedStorage += disk.used;
+ });
+ storageUsageRate = (usedStorage / totalStorage) * 100;
+ } catch (e) {
+ console.error("解析磁盘数据失败:", e);
+ }
+ }
+
+ // 解析并聚合所有网络接口数据
+ if (latestMetric.network_metrics) {
+ try {
+ const networks = JSON.parse(
+ latestMetric.network_metrics
+ ) as NetworkMetric[];
+ networks.forEach((network) => {
+ totalUpload += network.bytes_sent;
+ totalDownload += network.bytes_recv;
+ });
+ } catch (e) {
+ console.error("解析网络数据失败:", e);
+ }
+ }
+ }
+
+ return (
+
+ {/* 顶部信息栏 */}
+
+
+
+
+
+
+ {agent.name}
+
+
+ {agent.status === "active" ? "在线" : "离线"}
+
+
+
+
+ {/* 主要指标 - 响应式网格布局 */}
+
+
+
+
+
+
+
+ {/* 底部统计信息 */}
+
+
+ 总上传:{formatBytes(totalUpload)}
+ 总下载:{formatBytes(totalDownload)}
+
+
+
+ );
+};
+
+export default AgentStatusBar;
diff --git a/frontend/src/components/MonitorCard.tsx b/frontend/src/components/MonitorCard.tsx
index be95e21..0e42906 100644
--- a/frontend/src/components/MonitorCard.tsx
+++ b/frontend/src/components/MonitorCard.tsx
@@ -7,7 +7,7 @@ import {
import { Card, Badge } from "./ui";
import { MonitorWithDailyStatsAndStatusHistory } from "../types/monitors";
import { useTranslation } from "react-i18next";
-import StatusBar from "./StatusBar";
+import StatusBar from "./MonitorStatusBar";
import ResponseTimeChart from "./ResponseTimeChart";
interface MonitorCardProps {
diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/MonitorStatusBar.tsx
similarity index 100%
rename from frontend/src/components/StatusBar.tsx
rename to frontend/src/components/MonitorStatusBar.tsx
diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx
index 0205413..1d6d6b6 100644
--- a/frontend/src/components/ui/badge.tsx
+++ b/frontend/src/components/ui/badge.tsx
@@ -1,8 +1,8 @@
-import * as React from "react"
-import { Slot } from "@radix-ui/react-slot"
-import { cva, type VariantProps } from "class-variance-authority"
+import * as React from "react";
+import { Slot } from "@radix-ui/react-slot";
+import { cva, type VariantProps } from "class-variance-authority";
-import { cn } from "@/lib/utils"
+import { cn } from "@/lib/utils";
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
@@ -18,29 +18,42 @@ const badgeVariants = cva(
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
+ color: {
+ default: "",
+ green:
+ "border-transparent bg-green-500/15 text-green-700 dark:text-green-400",
+ red: "border-transparent bg-red-500/15 text-red-700 dark:text-red-400",
+ yellow:
+ "border-transparent bg-yellow-500/15 text-yellow-700 dark:text-yellow-400",
+ amber:
+ "border-transparent bg-amber-500/15 text-amber-700 dark:text-amber-400",
+ gray: "border-transparent bg-gray-500/15 text-gray-700 dark:text-gray-400",
+ },
},
defaultVariants: {
variant: "default",
+ color: "default",
},
}
-)
+);
function Badge({
className,
variant,
+ color,
asChild = false,
...props
}: React.ComponentProps<"span"> &
- VariantProps & { asChild?: boolean }) {
- const Comp = asChild ? Slot : "span"
+ VariantProps & { asChild?: boolean; color?: string }) {
+ const Comp = asChild ? Slot : "span";
return (
- )
+ );
}
-export { Badge, badgeVariants }
+export { Badge, badgeVariants };
diff --git a/frontend/src/i18n/zh-CN.ts b/frontend/src/i18n/zh-CN.ts
index 1063fdf..1805de5 100644
--- a/frontend/src/i18n/zh-CN.ts
+++ b/frontend/src/i18n/zh-CN.ts
@@ -18,25 +18,18 @@ const zhCN = {
"common.cancel": "取消",
"common.delete": "删除",
"common.edit": "编辑",
- "common.create": "创建",
+
"common.back": "返回",
- "common.search": "搜索",
- "common.filter": "筛选",
+
"common.actions": "操作",
"common.status": "状态",
- "common.name": "名称",
- "common.description": "描述",
+
"common.type": "类型",
- "common.url": "URL",
- "common.created": "创建时间",
- "common.updated": "更新时间",
- "common.yes": "是",
+
"common.no": "否",
- "common.confirm": "确认",
- "common.success": "成功",
+
"common.error": "错误",
- "common.warning": "警告",
- "common.info": "信息",
+
"common.retry": "重试",
"common.refresh": "刷新",
"common.deleting": "删除中...",
@@ -45,25 +38,22 @@ const zhCN = {
"common.loadingError": "加载出错",
"common.notFound": "未找到",
"common.backToList": "返回列表",
- "common.success.deleted": "已成功删除",
- "common.success.updated": "更新成功",
+
"common.error.delete": "删除失败,请重试",
- "common.error.update": "更新失败",
+
"common.error.fetch": "获取数据失败",
- "common.required": "必填",
- "common.optional": "可选",
+
"common.copy": "复制",
"common.copied": "已复制",
"common.deleteConfirmation": "确认删除",
"common.deleteConfirmMessage": "您确定要删除吗?此操作无法撤销。",
"common.second": "秒",
"common.seconds": "秒",
- "common.minute": "分钟",
- "common.minutes": "分钟",
+
"common.date": "日期",
"common.close": "关闭",
"common.unknown": "未知",
- "common.comingSoon": "即将推出的功能",
+
"common.default": "默认",
// 语言选择
@@ -259,46 +249,46 @@ const zhCN = {
"agent.deleteError": "删除客户端失败",
// 客户端监控指标
- "agent.metrics.cpu":{
- "title": "CPU",
- "usage": "使用情况",
- "cores": "核心数",
- "model": "型号",
+ "agent.metrics.cpu": {
+ title: "CPU",
+ usage: "使用情况",
+ cores: "核心数",
+ model: "型号",
},
"agent.metrics.memory": {
- "title": "内存",
- "total": "总内存",
- "used": "已使用内存",
- "free": "可用内存",
- "usage": "使用情况",
- "usageRate": "使用率",
+ title: "内存",
+ total: "总内存",
+ used: "已使用内存",
+ free: "可用内存",
+ usage: "使用情况",
+ usageRate: "使用率",
},
"agent.metrics.disk": {
- "title": "磁盘",
- "usage": "使用情况",
- "device": "设备",
- "mountPoint": "挂载点",
- "total": "总空间",
- "used": "已使用空间",
- "free": "可用空间",
- "usageRate": "使用率",
- "fsType": "文件系统类型"
+ title: "磁盘",
+ usage: "使用情况",
+ device: "设备",
+ mountPoint: "挂载点",
+ total: "总空间",
+ used: "已使用空间",
+ free: "可用空间",
+ usageRate: "使用率",
+ fsType: "文件系统类型",
},
"agent.metrics.network": {
- "title": "网络",
- "rate": "速率",
- "received": "接收",
- "interface": "接口",
- "sent": "发送",
- "packetsReceived": "接收数据包",
- "packetsSent": "发送数据包",
+ title: "网络",
+ rate: "速率",
+ received: "接收",
+ interface: "接口",
+ sent: "发送",
+ packetsReceived: "接收数据包",
+ packetsSent: "发送数据包",
},
"agent.metrics.load": {
- "title": "负载",
+ title: "负载",
"1min": "1分钟负载",
"5min": "5分钟负载",
"15min": "15分钟负载",
- "minute": "分钟",
+ minute: "分钟",
},
// 创建/编辑客户端
@@ -368,8 +358,6 @@ const zhCN = {
// 状态页
"statusPage.title": "系统状态",
"statusPage.allOperational": "所有系统正常运行",
- "statusPage.lastUpdated": "最后更新",
- "statusPage.justNow": "刚刚",
"statusPage.apiServices": "API服务状态",
"statusPage.agentStatus": "客户端监控状态",
@@ -394,39 +382,16 @@ const zhCN = {
"statusPageConfig.configSaved": "状态页配置已保存",
"statusPageConfig.saveError": "保存状态页配置失败",
"statusPageConfig.fetchingConfig": "开始获取状态页配置...",
- "statusPageConfig.configFromProperty": "从config属性获取的状态页配置:",
- "statusPageConfig.tryingDirectResponse":
- "API响应似乎直接包含配置数据,尝试直接使用",
- "statusPageConfig.parseError": "尝试解析API响应失败:",
- "statusPageConfig.receivedConfig": "获取到的状态页配置:",
- "statusPageConfig.monitorListType": "监控项列表类型:",
- "statusPageConfig.monitorCount": "监控项数量:",
"statusPageConfig.configMonitor": "配置中的监控项",
- "statusPageConfig.noValidConfig": "未获取到有效的状态页配置或配置为空",
- "statusPageConfig.invalidResponse": "API响应无效",
- "statusPageConfig.fetchingMonitors": "开始获取所有监控项...",
+
"statusPageConfig.monitorsResponse": "获取所有监控项响应:",
- "statusPageConfig.foundMonitor": "找到配置中的监控项",
- "statusPageConfig.originalStatus": "原始选中状态",
- "statusPageConfig.processedStatus": "处理后的选中状态",
- "statusPageConfig.notFoundMonitor": "未在配置中找到监控项",
- "statusPageConfig.finalMonitorResult": "最终监控项处理结果",
+
"statusPageConfig.selectedStatus": "选中状态",
- "statusPageConfig.processedMonitorList": "处理后的监控项列表",
- "statusPageConfig.fetchingAgents": "开始获取所有客户端...",
- "statusPageConfig.foundAgent": "找到配置中的客户端",
- "statusPageConfig.notFoundAgent": "未在配置中找到客户端",
- "statusPageConfig.finalAgentResult": "最终客户端处理结果",
- "statusPageConfig.processedAgentList": "处理后的客户端列表",
- "statusPageConfig.fetchMonitorsError": "获取监控数据失败",
- "statusPageConfig.fetchDataError": "获取数据失败",
- "statusPageConfig.configLoaded": "状态页配置加载完成,验证监控项选中状态:",
+
"statusPageConfig.invalidAgentId": "尝试更改无效的客户端ID:",
- "statusPageConfig.savingConfig": "准备保存配置对象:",
+
"statusPageConfig.selectedMonitors": "选中的监控项:",
- "statusPageConfig.selectedMonitorIds": "选中的监控项ID列表:",
- "statusPageConfig.callingSaveApi": "开始调用保存配置API...",
- "statusPageConfig.saveApiResponse": "保存配置API响应:",
+
"statusPageConfig.serviceRendering": "服务渲染",
"statusPageConfig.agentRendering": "客户端渲染",
"statusPageConfig.monitorStatusChange": "监控项状态变更",
diff --git a/frontend/src/pages/agents/AgentDetail.tsx b/frontend/src/pages/agents/AgentDetail.tsx
index 4ad12a2..68c8908 100644
--- a/frontend/src/pages/agents/AgentDetail.tsx
+++ b/frontend/src/pages/agents/AgentDetail.tsx
@@ -2,7 +2,7 @@ import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Box, Flex, Heading, Text, Grid, Container } from "@radix-ui/themes";
-import { Button, Card, Badge, Avatar, AvatarFallback } from "@/components/ui";
+import { Button, Card, Badge } from "@/components/ui";
import {
ArrowLeftIcon,
@@ -15,6 +15,7 @@ import {
DesktopIcon,
GlobeIcon,
Link2Icon,
+ PersonIcon,
} from "@radix-ui/react-icons";
import { getAgent, deleteAgent, getAgentMetrics } from "../../api/agents";
import { Agent } from "../../types/agents";
@@ -233,169 +234,164 @@ const AgentDetail = () => {
+
+
+ {/* 系统信息卡片 */}
+
+
+ {t("agent.systemInfo")}
+
+
+
+
+ {t("agents.table.name")}:
+
+
+ {agent.name}
+
+
+
+
+
+
+
+ {t("agent.os")}:
+
+
+ {agent.os || t("common.notFound")}
+
+
+
-
-
-
-
- {agent.name.charAt(0)}
-
-
- {agent.name}
-
-
-
-
-
- {/* 系统信息卡片 */}
-
-
- {t("agent.systemInfo")}
+
+
+
+
+ {t("agent.version")}:
+
+
+ {agent.version || t("common.notFound")}
+
+
+
-
-
-
-
- {t("agent.os")}:
-
-
- {agent.os || t("common.notFound")}
-
-
-
+
+
+
+
+ {t("agent.hostname")}:
+
+
+ {agent.hostname || t("common.notFound")}
+
+
+
-
-
-
-
- {t("agent.version")}:
-
-
- {agent.version || t("common.notFound")}
-
-
-
-
-
-
-
-
- {t("agent.hostname")}:
-
-
- {agent.hostname || t("common.notFound")}
-
-
-
-
-
-
-
-
- {t("agent.ipAddress")}:
-
- {agent.ip_addresses ? (
- (() => {
- try {
- const ipArray = JSON.parse(
- String(agent.ip_addresses)
+
+
+
+
+ {t("agent.ipAddress")}:
+
+ {agent.ip_addresses ? (
+ (() => {
+ try {
+ const ipArray = JSON.parse(String(agent.ip_addresses));
+ if (Array.isArray(ipArray) && ipArray.length > 0) {
+ return (
+
+ {ipArray[0]}
+ {ipArray.length > 1
+ ? ` (+${ipArray.length - 1})`
+ : ""}
+
);
- if (Array.isArray(ipArray) && ipArray.length > 0) {
- return (
-
- {ipArray[0]}
- {ipArray.length > 1
- ? ` (+${ipArray.length - 1})`
- : ""}
-
- );
- } else {
- return (
-
- {String(agent.ip_addresses)}
-
- );
- }
- } catch (e) {
+ } else {
return (
{String(agent.ip_addresses)}
);
}
- })()
- ) : (
-
- {t("common.unknown")}
-
- )}
-
-
-
-
-
-
-
- {t("agent.uptime")}:
-
-
- {formatUptime(agent)}
-
-
-
-
-
-
-
-
- {t("agent.lastUpdated")}:
-
-
- {formatDateTime(agent.updated_at)}
-
-
-
-
- {/* 如果存在多个IP地址,展示完整列表 */}
- {agent.ip_addresses &&
- (() => {
- try {
- const ipArray = JSON.parse(String(agent.ip_addresses));
- if (Array.isArray(ipArray) && ipArray.length > 1) {
+ } catch (e) {
return (
-
-
- {ipArray.slice(1).map((ip, index) => (
-
- {ip}
-
- ))}
-
-
+
+ {String(agent.ip_addresses)}
+
);
}
- return null;
- } catch (e) {
- return null;
- }
- })()}
-
-
+ })()
+ ) : (
+
+ {t("common.unknown")}
+
+ )}
+
+
- {/* Agent 资源信息卡片 */}
-
-
- {t("agent.metrics")}
-
-
-
-
-
+
+
+
+
+ {t("agent.uptime")}:
+
+
+ {formatUptime(agent)}
+
+
+
+
+
+
+
+
+ {t("agent.lastUpdated")}:
+
+
+ {formatDateTime(agent.updated_at)}
+
+
+
+
+ {/* 如果存在多个IP地址,展示完整列表 */}
+ {agent.ip_addresses &&
+ (() => {
+ try {
+ const ipArray = JSON.parse(String(agent.ip_addresses));
+ if (Array.isArray(ipArray) && ipArray.length > 1) {
+ return (
+
+
+ {ipArray.slice(1).map((ip, index) => (
+
+ {ip}
+
+ ))}
+
+
+ );
+ }
+ return null;
+ } catch (e) {
+ return null;
+ }
+ })()}
+
+
+
+ {/* Agent 资源信息卡片 */}
+
+
+ {t("agent.metrics")}
+
+
+
+
);
diff --git a/frontend/src/pages/agents/AgentsList.tsx b/frontend/src/pages/agents/AgentsList.tsx
index 22e3245..94d970f 100644
--- a/frontend/src/pages/agents/AgentsList.tsx
+++ b/frontend/src/pages/agents/AgentsList.tsx
@@ -37,10 +37,14 @@ import {
ViewGridIcon,
TrashIcon,
} from "@radix-ui/react-icons";
-import { getAllAgents, deleteAgent, getAgentMetrics } from "../../api/agents";
-import AgentCard from "../../components/AgentCard";
+import {
+ getAllAgents,
+ deleteAgent,
+ getLatestAgentMetrics,
+} from "../../api/agents";
+import AgentStatusBar from "../../components/AgentStatusBar";
import { useTranslation } from "react-i18next";
-import { Agent } from "../../types";
+import { AgentWithLatestMetrics } from "../../types";
// 定义客户端状态颜色映射
const statusColors: Record = {
@@ -52,7 +56,7 @@ const statusColors: Record = {
const AgentsList = () => {
const navigate = useNavigate();
- const [agents, setAgents] = useState([]);
+ const [agents, setAgents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
@@ -84,20 +88,25 @@ const AgentsList = () => {
const agentsWithMetrics = await Promise.all(
response.agents.map(async (agent) => {
// 获取指定客户端的指标数据
- const metricsResponse = await getAgentMetrics(agent.id);
+ const metricsResponse = await getLatestAgentMetrics(agent.id);
if (!metricsResponse.success) {
console.error("获取指标数据失败:", metricsResponse.message);
- return agent; // 返回原始客户端数据
+ return { ...agent, metrics: undefined } as AgentWithLatestMetrics;
}
- const metrics = metricsResponse.agent;
- console.log("获取到的 metrics 数据: ", metrics);
- console.log("获取到的 agent 数据: ", agent);
- return { ...agent, metrics };
+ // 确保我们只取数组中的第一条记录(最新的)
+ const latestMetric = Array.isArray(metricsResponse.agent)
+ ? metricsResponse.agent[0]
+ : metricsResponse.agent;
+
+ return {
+ ...agent,
+ metrics: latestMetric,
+ } as AgentWithLatestMetrics;
})
);
- setAgents(agentsWithMetrics);
+ console.log("获取到的 agentsWithMetrics 数据: ", agentsWithMetrics);
+ setAgents(agentsWithMetrics as AgentWithLatestMetrics[]);
}
- console.log("获取到的 agent 数据: ", response);
setLoading(false);
};
@@ -143,7 +152,7 @@ const AgentsList = () => {
{agents.map((agent) => (
-
+
{
};
return (
-
+
-
-
+
+
{/* 提示信息 */}
diff --git a/frontend/src/pages/agents/EditAgent.tsx b/frontend/src/pages/agents/EditAgent.tsx
index 9fea31c..f7e918b 100644
--- a/frontend/src/pages/agents/EditAgent.tsx
+++ b/frontend/src/pages/agents/EditAgent.tsx
@@ -100,52 +100,59 @@ const EditAgent = () => {
return (
-
-
-
-
- {t("agent.form.editingClient", { name: formData.name })}
-
-
-
-
-
);
};
diff --git a/frontend/src/pages/monitors/CreateMonitor.tsx b/frontend/src/pages/monitors/CreateMonitor.tsx
index 5f2a8cd..98b15ab 100644
--- a/frontend/src/pages/monitors/CreateMonitor.tsx
+++ b/frontend/src/pages/monitors/CreateMonitor.tsx
@@ -16,6 +16,7 @@ import {
SelectContent,
SelectTrigger,
SelectItem,
+ SelectValue,
Table,
TableCell,
TableRow,
@@ -69,15 +70,6 @@ const CreateMonitor = () => {
) => {
const newHeaders = [...headers];
newHeaders[index][field] = value;
-
- // 如果最后一行有输入内容,添加新的空行
- if (
- index === headers.length - 1 &&
- (newHeaders[index].key || newHeaders[index].value)
- ) {
- newHeaders.push({ key: "", value: "" });
- }
-
setHeaders(newHeaders);
};
@@ -151,229 +143,215 @@ const CreateMonitor = () => {
const showBodyField = ["POST", "PUT", "PATCH"].includes(formData.method);
return (
-
-
-
-
-
- {t("monitor.form.title.create")}
-
+
+
+
+
+ {t("monitor.form.title.create")}
-
-
-
-
-
+
+
+
+ {t("monitor.form.expectedStatus")} *
+
+
+
+
+
+
+ {t("monitor.form.headers")}
+
+
+
+
+
+ {t("monitor.form.headerName")}
+ {t("monitor.form.headerValue")}
+
+
+
+
+ {headers.map((header, index) => (
+
+
+
+ handleHeaderChange(index, "key", e.target.value)
+ }
+ />
+
+
+
+ handleHeaderChange(
+ index,
+ "value",
+ e.target.value
+ )
+ }
+ />
+
+
+ removeHeader(index)}
+ >
+
+
+
+
+ ))}
+
+
+
+
+
+
+ {t("monitor.form.headersHelp")}
+
+
+
+ {showBodyField && (
+
+
+ {t("monitor.form.body")}
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
);
};
diff --git a/frontend/src/pages/monitors/EditMonitor.tsx b/frontend/src/pages/monitors/EditMonitor.tsx
index c80e8db..e41cbb9 100644
--- a/frontend/src/pages/monitors/EditMonitor.tsx
+++ b/frontend/src/pages/monitors/EditMonitor.tsx
@@ -17,6 +17,7 @@ import {
SelectContent,
SelectItem,
SelectTrigger,
+ SelectValue,
Table,
TableHeader,
TableBody,
@@ -148,15 +149,6 @@ const EditMonitor = () => {
) => {
const newHeaders = [...headers];
newHeaders[index][field] = value;
-
- // 如果最后一行有输入内容,添加新的空行
- if (
- index === headers.length - 1 &&
- (newHeaders[index].key || newHeaders[index].value)
- ) {
- newHeaders.push({ key: "", value: "" });
- }
-
setHeaders(newHeaders);
};
@@ -254,233 +246,222 @@ const EditMonitor = () => {
}
return (
-
-
-
-
+
+
+
+
+
+ {t("monitor.form.title.edit")}: {formData.name}
+
+
+
+
+
+
+
+
+
+ {t("monitor.form.name")} *
+
+
+
+
+
+
+ URL *
+
+
+
+
+
+
+ {t("monitor.form.method")} *
+
+
+
+
+
+
+
+ {t("monitor.form.interval")} *
+
+
+
+ {t("monitor.form.intervalMin")}
+
+
+
+
+
+ {t("monitor.form.timeout")} *
+
+
+
+
+
+
+
+ {t("monitor.form.expectedStatus")} *
+
+
+
+
+
+
+ {t("monitor.form.headers")}
+
+
+
+
+
+ {t("monitor.form.headerName")}
+ {t("monitor.form.headerValue")}
+
+
+
+ {headers.map((header, index) => (
+
+
+
+ handleHeaderChange(index, "key", e.target.value)
+ }
+ />
+
+
+
+ handleHeaderChange(
+ index,
+ "value",
+ e.target.value
+ )
+ }
+ />
+
+
+ removeHeader(index)}
+ >
+
+
+
+
+ ))}
+
+
+
+
+
+
+
+ {t("monitor.form.headersHelp")}
+
+
+
+ {showBodyField && (
+
+
+ {t("monitor.form.body")}
+
+
+
+ )}
+
+
+
+
+
-
- {t("monitor.form.title.edit")}: {formData.name}
-
-
-
-
-
-
-
-
-
-
- {t("monitor.form.name")} *
-
-
-
-
-
-
- URL *
-
-
-
-
-
-
- {t("monitor.form.method")} *
-
-
-
-
-
-
-
- {t("monitor.form.interval")} *
-
-
-
- {t("monitor.form.intervalMin")}
-
-
-
-
-
- {t("monitor.form.timeout")} *
-
-
-
-
-
-
-
- {t("monitor.form.expectedStatus")} *
-
-
-
-
-
-
- {t("monitor.form.headers")}
-
-
-
-
-
-
- {t("monitor.form.headerName")}
-
-
- {t("monitor.form.headerValue")}
-
-
-
-
- {headers.map((header, index) => (
-
-
-
- handleHeaderChange(
- index,
- "key",
- e.target.value
- )
- }
- />
-
-
-
- handleHeaderChange(
- index,
- "value",
- e.target.value
- )
- }
- />
-
-
- removeHeader(index)}
- >
-
-
-
-
- ))}
-
-
-
-
-
-
-
- {t("monitor.form.headersHelp")}
-
-
-
- {showBodyField && (
-
-
- {t("monitor.form.body")}
-
-
-
- )}
-
-
-
-
-
-
-
-
-
-
-
+
+
);
};
diff --git a/frontend/src/pages/monitors/MonitorDetail.tsx b/frontend/src/pages/monitors/MonitorDetail.tsx
index 184042f..cec8efa 100644
--- a/frontend/src/pages/monitors/MonitorDetail.tsx
+++ b/frontend/src/pages/monitors/MonitorDetail.tsx
@@ -1,6 +1,6 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
-import { Box, Flex, Heading, Text, Grid,Container } from "@radix-ui/themes";
+import { Box, Flex, Heading, Text, Grid, Container } from "@radix-ui/themes";
import { Button, Card, Badge } from "@/components/ui";
import {
ArrowLeftIcon,
@@ -19,7 +19,7 @@ import {
import { MonitorWithDailyStatsAndStatusHistory } from "../../types/monitors";
import { useTranslation } from "react-i18next";
import ResponseTimeChart from "../../components/ResponseTimeChart";
-import StatusBar from "../../components/StatusBar";
+import StatusBar from "../../components/MonitorStatusBar";
// 将范围状态码转换为可读形式(2 -> 2xx, 3 -> 3xx 等)
const formatStatusCode = (code: number | undefined): string => {
@@ -90,10 +90,8 @@ const MonitorDetail = () => {
setLoading(true);
const response = await checkMonitor(parseInt(id));
if (response.success) {
-
- toast.success(t("monitor.checkCompleted"));
+ toast.success(t("monitor.checkCompleted"));
} else {
-
toast.error(t("monitor.checkFailed"));
}
setLoading(false);
@@ -105,7 +103,6 @@ const MonitorDetail = () => {
const response = await deleteMonitor(parseInt(id));
if (response.success) {
-
toast.success(t("monitor.deleteSuccess")); // Added
// 短暂延迟后导航,让用户有时间看到提示
@@ -113,7 +110,6 @@ const MonitorDetail = () => {
navigate("/monitors");
}, 1500);
} else {
-
toast.error(t("monitor.deleteFailed")); // Added
}
};
@@ -149,94 +145,91 @@ const MonitorDetail = () => {
}
return (
-
-
-
-
- {monitor.name}
-
- {monitor.status === "up"
- ? t("monitor.status.normal")
- : monitor.status === "down"
- ? t("monitor.status.failure")
- : t("monitor.status.pending")}
-
-
-
-
-
-
-
+
+
+
+
+ {monitor.name}
+
+ {monitor.status === "up"
+ ? t("monitor.status.normal")
+ : monitor.status === "down"
+ ? t("monitor.status.failure")
+ : t("monitor.status.pending")}
+
-
-
-
- {t("monitor.detailInfo")}
-
- URL:
- {monitor.url}
- {t("monitor.method")}:
- {monitor.method}
- {t("monitor.interval")}:
-
- {monitor.interval} {t("common.seconds")}
-
- {t("monitor.timeout")}:
-
- {monitor.timeout} {t("common.seconds")}
-
- {t("monitor.expectedStatus")}:
- {formatStatusCode(monitor.expected_status)}
- {t("monitor.createTime")}:
- {new Date(monitor.created_at).toLocaleString()}
- {t("monitor.headers")}:
-
- {typeof monitor.headers === "string"
- ? monitor.headers
- : JSON.stringify(monitor.headers)}
-
- {t("monitor.body")}:
-
- {monitor.body || "-"}
-
-
-
-
- {/* 添加响应时间图表 */}
-
-
- {t("monitor.oneDayHistory")}
-
-
-
-
-
-
-
- {t("monitor.MonthsHistory")}
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {t("monitor.detailInfo")}
+
+ URL:
+ {monitor.url}
+ {t("monitor.method")}:
+ {monitor.method}
+ {t("monitor.interval")}:
+
+ {monitor.interval} {t("common.seconds")}
+
+ {t("monitor.timeout")}:
+
+ {monitor.timeout} {t("common.seconds")}
+
+ {t("monitor.expectedStatus")}:
+ {formatStatusCode(monitor.expected_status)}
+ {t("monitor.createTime")}:
+ {new Date(monitor.created_at).toLocaleString()}
+ {t("monitor.headers")}:
+
+ {typeof monitor.headers === "string"
+ ? monitor.headers
+ : JSON.stringify(monitor.headers)}
+
+ {t("monitor.body")}:
+
+ {monitor.body || "-"}
+
+
+
+
+ {/* 添加响应时间图表 */}
+
+
+ {t("monitor.oneDayHistory")}
+
+
+
+
+
+
+
+ {t("monitor.MonthsHistory")}
+
+
+
+
+
+
);
};
diff --git a/frontend/src/pages/monitors/MonitorsList.tsx b/frontend/src/pages/monitors/MonitorsList.tsx
index a4a4b57..c497ba0 100644
--- a/frontend/src/pages/monitors/MonitorsList.tsx
+++ b/frontend/src/pages/monitors/MonitorsList.tsx
@@ -199,7 +199,7 @@ const MonitorsList = () => {
}
return (
-
+
{t("monitors.pageTitle")}
diff --git a/frontend/src/pages/status/StatusPage.tsx b/frontend/src/pages/status/StatusPage.tsx
index d1835af..ecdeff7 100644
--- a/frontend/src/pages/status/StatusPage.tsx
+++ b/frontend/src/pages/status/StatusPage.tsx
@@ -1,18 +1,23 @@
import { useState, useEffect } from "react";
import { Box, Flex, Heading, Text, Grid, Theme } from "@radix-ui/themes";
-import { Badge } from "@/components/ui";
import { getStatusPageData } from "../../api/status";
import AgentCard from "../../components/AgentCard";
import MonitorCard from "../../components/MonitorCard";
+import AgentStatusBar from "../../components/AgentStatusBar";
import { useTranslation } from "react-i18next";
-import { Agent, MonitorWithDailyStatsAndStatusHistory } from "../../types";
-import { getAgentMetrics } from "../../api";
+import {
+ Agent,
+ MonitorWithDailyStatsAndStatusHistory,
+ MetricHistory,
+ AgentWithLatestMetrics,
+} from "../../types";
+import { getLatestAgentMetrics, getAgentMetrics } from "../../api/agents";
const StatusPage = () => {
const { t } = useTranslation();
const [data, setData] = useState<{
monitors: MonitorWithDailyStatsAndStatusHistory[];
- agents: Agent[];
+ agents: AgentWithLatestMetrics[];
}>({
monitors: [],
agents: [],
@@ -23,45 +28,53 @@ const StatusPage = () => {
t("statusPage.allOperational")
);
const [error, setError] = useState(null);
+ const [selectedAgent, setSelectedAgent] =
+ useState(null);
+ const [selectedAgentMetrics, setSelectedAgentMetrics] = useState<
+ MetricHistory[] | null
+ >(null);
+ const [cardLoading, setCardLoading] = useState(false);
// 从API获取数据
useEffect(() => {
fetchData();
- // 设置定时刷新,每分钟更新数据
+ // 设置定时刷新,每3分钟更新数据
const intervalId = setInterval(() => {
fetchData();
- }, 180000); // 180000ms = 3分钟
-
- // 组件卸载时清除定时器和取消请求
- return () => {
- clearInterval(intervalId);
- };
- }, []); // 依赖于 t 函数
+ }, 180000);
+ return () => clearInterval(intervalId);
+ }, []);
// 获取数据
const fetchData = async () => {
setLoading(true);
const response = await getStatusPageData();
-
if (response) {
- // 设置页面标题和描述
setPageTitle(response.title || t("statusPage.title"));
setPageDescription(
response.description || t("statusPage.allOperational")
);
- if (response.agents) {
- // 获取每个代理的指标数据
- await Promise.all(
- response.agents.map(async (agent) => {
- const metrics = await getAgentMetrics(agent.id);
- agent.metrics = metrics.agent || [];
- })
- );
- }
- console.log(response);
+ // 只保留 AgentWithLatestMetrics 需要的字段,且 metrics 字段为 MetricHistory | undefined
+ let agentsWithLatestMetrics: AgentWithLatestMetrics[] = (
+ response.agents || []
+ ).map((a: Agent) => {
+ const { metrics, ...rest } = a;
+ return { ...rest };
+ });
+ // 只请求最新指标
+ await Promise.all(
+ agentsWithLatestMetrics.map(async (agent) => {
+ const metricsRes = await getLatestAgentMetrics(agent.id);
+ // 确保我们只取数组中的第一条记录(最新的)
+ const latestMetric = Array.isArray(metricsRes.agent)
+ ? metricsRes.agent[0]
+ : metricsRes.agent;
+ agent.metrics = latestMetric;
+ })
+ );
setData({
monitors: response.monitors || [],
- agents: response.agents || [],
+ agents: agentsWithLatestMetrics,
});
} else {
setError(t("statusPage.fetchError"));
@@ -69,6 +82,23 @@ const StatusPage = () => {
setLoading(false);
};
+ // 点击 agent 卡片时,获取完整指标
+ const handleAgentClick = async (agent: AgentWithLatestMetrics) => {
+ // 如果点击的是当前展开的 agent,则收起
+ if (selectedAgent?.id === agent.id) {
+ setSelectedAgent(null);
+ setSelectedAgentMetrics(null);
+ return;
+ }
+
+ setSelectedAgent(agent);
+ setCardLoading(true);
+ setSelectedAgentMetrics(null);
+ const metricsRes = await getAgentMetrics(agent.id);
+ setSelectedAgentMetrics(metricsRes.success ? metricsRes.agent || [] : []);
+ setCardLoading(false);
+ };
+
// 错误显示
if (error) {
return (
@@ -101,7 +131,7 @@ const StatusPage = () => {
return (
-
+
{/* 状态页标题区域 */}
{
{pageDescription}
-
-
- {t("statusPage.lastUpdated")}: {t("statusPage.justNow")}
-
-
+ {/* 客户端监控状态 */}
+ {data.agents.length > 0 && (
+
+
+ {t("statusPage.agentStatus")}
+
+
+ {data.agents.map((agent) => (
+
+
handleAgentClick(agent)}
+ >
+
+
+ {/* 展开的详情区域 */}
+ {selectedAgent?.id === agent.id && (
+
+ {cardLoading ? (
+
+
+ {t("common.loading")}
+
+
+ ) : (
+
+ )}
+
+ )}
+
+ ))}
+
+
+ )}
+
{/* API服务状态 */}
{data.monitors.length > 0 && (
@@ -131,23 +199,7 @@ const StatusPage = () => {
{data.monitors.map((monitor) => (
-
- ))}
-
-
- )}
-
- {/* 客户端监控状态 */}
- {data.agents.length > 0 && (
-
-
- {t("statusPage.agentStatus")}
-
-
- {data.agents.map((agent) => (
-
-
-
+
))}
diff --git a/frontend/src/types/agents.ts b/frontend/src/types/agents.ts
index 0c62cf7..906e0a4 100644
--- a/frontend/src/types/agents.ts
+++ b/frontend/src/types/agents.ts
@@ -15,6 +15,19 @@ export interface Agent {
metrics?: MetricHistory[];
}
+export interface AgentWithLatestMetrics {
+ id: number;
+ name: string;
+ hostname?: string;
+ ip_addresses?: string;
+ status: "active" | "inactive" | "connecting" | "unknown";
+ version?: string;
+ os?: string;
+ created_at: string;
+ updated_at: string;
+ metrics?: MetricHistory;
+}
+
export interface AgentResponse {
success: boolean;
agent?: Agent;