feat: add agent_status_bar and optimism style

This commit is contained in:
zaunist committed 2025-05-26 02:13:01 +08:00
1 parent 2a9e6c06da
commit 0644f0d455
21 files changed
+1174 -917

No files matched your search

+20 -13
View File
@@ -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 };
+5 -1
View File
@@ -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({
+8
View File
@@ -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();
}
+4
View File
@@ -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);
}
+11
View File
@@ -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;
};
+1 -1
View File
@@ -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";
+216
View File
@@ -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;
}) => (
<div className="w-full h-1.5 bg-gray-100 dark:bg-gray-700 rounded-full overflow-hidden">
<div
style={{ width: `${percent}%`, background: color }}
className="h-full transition-all duration-300 rounded-full"
/>
</div>
);
const MetricCard = ({
label,
value,
subValue,
percent,
color,
}: {
label: string;
value: string;
subValue?: string;
percent?: number;
color?: string;
}) => (
<div className="flex flex-col space-y-1 min-w-[80px] flex-1">
<span className="text-xs text-gray-500 dark:text-gray-400">{label}</span>
<span className="text-base font-semibold">{value}</span>
{subValue && <span className="text-xs text-gray-500">{subValue}</span>}
{percent !== undefined && color && (
<ProgressBar percent={percent} color={color} />
)}
</div>
);
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<AgentStatusBarProps> = ({
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 (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-100 dark:border-gray-700 p-4 transition-all">
{/* 顶部信息栏 */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Badge variant="secondary" className="flex items-center gap-1">
<FontAwesomeIcon icon={getOsIcon(agent.os)} className="w-4 h-4" />
</Badge>
<span className="text-lg font-bold dark:text-white">
{agent.name}
</span>
<Badge
variant="outline"
color={agent.status === "active" ? "green" : "gray"}
>
{agent.status === "active" ? "在线" : "离线"}
</Badge>
</div>
</div>
{/* 主要指标 - 响应式网格布局 */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-4">
<MetricCard
label="CPU"
value={formatPercent(latestMetric?.cpu_usage)}
subValue={latestMetric?.cpu_model}
percent={latestMetric?.cpu_usage}
color="#2d8cf0"
/>
<MetricCard
label="内存"
value={formatPercent(latestMetric?.memory_usage_rate)}
subValue={
latestMetric
? `${formatBytes(latestMetric.memory_used)} / ${formatBytes(
latestMetric.memory_total
)}`
: undefined
}
percent={latestMetric?.memory_usage_rate}
color="#faad14"
/>
<MetricCard
label="存储"
value={formatPercent(storageUsageRate)}
subValue={`${formatBytes(usedStorage)} / ${formatBytes(
totalStorage
)}`}
percent={storageUsageRate}
color="#13c2c2"
/>
<MetricCard
label="系统负载"
value={latestMetric?.load_1?.toFixed(2) || "-"}
subValue={
latestMetric
? `${latestMetric.load_5?.toFixed(
2
)} / ${latestMetric.load_15?.toFixed(2)}`
: undefined
}
/>
</div>
{/* 底部统计信息 */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 text-sm text-gray-600 dark:text-gray-300">
<div className="flex items-center gap-4">
<span>总上传:{formatBytes(totalUpload)}</span>
<span>总下载:{formatBytes(totalDownload)}</span>
</div>
</div>
</div>
);
};
export default AgentStatusBar;
+1 -1
View File
@@ -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 {
+23 -10
View File
@@ -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<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
VariantProps<typeof badgeVariants> & { asChild?: boolean; color?: string }) {
const Comp = asChild ? Slot : "span";
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
className={cn(badgeVariants({ variant, color }), className)}
{...props}
/>
)
);
}
export { Badge, badgeVariants }
export { Badge, badgeVariants };
+45 -80
View File
@@ -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": "监控项状态变更",
+146 -150
View File
@@ -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 = () => {
</Button>
</Flex>
</Flex>
<Box pt="3">
<Grid columns={{ initial: "1" }} gap="4">
{/* 系统信息卡片 */}
<Card>
<Flex direction="column" gap="2" className="ml-4">
<Heading size="3">{t("agent.systemInfo")}</Heading>
<Box>
<Flex align="center" gap="2">
<PersonIcon />
<Text as="div" size="2" weight="bold">
{t("agents.table.name")}:
</Text>
<Text as="div" size="2">
{agent.name}
</Text>
</Flex>
</Box>
<Box>
<Flex align="center" gap="2">
<DesktopIcon />
<Text as="div" size="2" weight="bold">
{t("agent.os")}:
</Text>
<Text as="div" size="2">
{agent.os || t("common.notFound")}
</Text>
</Flex>
</Box>
<Box>
<Card>
<Flex align="center" gap="4" mb="4">
<Avatar>
<AvatarFallback>{agent.name.charAt(0)}</AvatarFallback>
</Avatar>
<Box>
<Heading size="5">{agent.name}</Heading>
</Box>
</Flex>
</Card>
<Box pt="3">
<Grid columns={{ initial: "1" }} gap="4">
{/* 系统信息卡片 */}
<Card>
<Flex direction="column" gap="3">
<Heading size="3">{t("agent.systemInfo")}</Heading>
<Box>
<Flex align="center" gap="2">
<InfoCircledIcon />
<Text as="div" size="2" weight="bold">
{t("agent.version")}:
</Text>
<Text as="div" size="2">
{agent.version || t("common.notFound")}
</Text>
</Flex>
</Box>
<Box>
<Flex align="center" gap="2">
<DesktopIcon />
<Text as="div" size="2" weight="bold">
{t("agent.os")}:
</Text>
<Text as="div" size="2">
{agent.os || t("common.notFound")}
</Text>
</Flex>
</Box>
<Box>
<Flex align="center" gap="2">
<GlobeIcon />
<Text as="div" size="2" weight="bold">
{t("agent.hostname")}:
</Text>
<Text as="div" size="2">
{agent.hostname || t("common.notFound")}
</Text>
</Flex>
</Box>
<Box>
<Flex align="center" gap="2">
<InfoCircledIcon />
<Text as="div" size="2" weight="bold">
{t("agent.version")}:
</Text>
<Text as="div" size="2">
{agent.version || t("common.notFound")}
</Text>
</Flex>
</Box>
<Box>
<Flex align="center" gap="2">
<GlobeIcon />
<Text as="div" size="2" weight="bold">
{t("agent.hostname")}:
</Text>
<Text as="div" size="2">
{agent.hostname || t("common.notFound")}
</Text>
</Flex>
</Box>
<Box>
<Flex align="center" gap="2">
<Link2Icon />
<Text as="div" size="2" weight="bold">
{t("agent.ipAddress")}:
</Text>
{agent.ip_addresses ? (
(() => {
try {
const ipArray = JSON.parse(
String(agent.ip_addresses)
<Box>
<Flex align="center" gap="2">
<Link2Icon />
<Text as="div" size="2" weight="bold">
{t("agent.ipAddress")}:
</Text>
{agent.ip_addresses ? (
(() => {
try {
const ipArray = JSON.parse(String(agent.ip_addresses));
if (Array.isArray(ipArray) && ipArray.length > 0) {
return (
<Text as="div" size="2">
{ipArray[0]}
{ipArray.length > 1
? ` (+${ipArray.length - 1})`
: ""}
</Text>
);
if (Array.isArray(ipArray) && ipArray.length > 0) {
return (
<Text as="div" size="2">
{ipArray[0]}
{ipArray.length > 1
? ` (+${ipArray.length - 1})`
: ""}
</Text>
);
} else {
return (
<Text as="div" size="2">
{String(agent.ip_addresses)}
</Text>
);
}
} catch (e) {
} else {
return (
<Text as="div" size="2">
{String(agent.ip_addresses)}
</Text>
);
}
})()
) : (
<Text as="div" size="2" color="gray">
{t("common.unknown")}
</Text>
)}
</Flex>
</Box>
<Box>
<Flex align="center" gap="2">
<LapTimerIcon />
<Text as="div" size="2" weight="bold">
{t("agent.uptime")}:
</Text>
<Text as="div" size="2">
{formatUptime(agent)}
</Text>
</Flex>
</Box>
<Box>
<Flex align="center" gap="2">
<ClockIcon />
<Text as="div" size="2" weight="bold">
{t("agent.lastUpdated")}:
</Text>
<Text as="div" size="2">
{formatDateTime(agent.updated_at)}
</Text>
</Flex>
</Box>
{/* 如果存在多个IP地址,展示完整列表 */}
{agent.ip_addresses &&
(() => {
try {
const ipArray = JSON.parse(String(agent.ip_addresses));
if (Array.isArray(ipArray) && ipArray.length > 1) {
} catch (e) {
return (
<Box pl="6" mt="1">
<Flex direction="column" gap="1">
{ipArray.slice(1).map((ip, index) => (
<Text key={index} size="2" color="gray">
{ip}
</Text>
))}
</Flex>
</Box>
<Text as="div" size="2">
{String(agent.ip_addresses)}
</Text>
);
}
return null;
} catch (e) {
return null;
}
})()}
</Flex>
</Card>
})()
) : (
<Text as="div" size="2" color="gray">
{t("common.unknown")}
</Text>
)}
</Flex>
</Box>
{/* Agent 资源信息卡片 */}
<Card>
<Flex direction="column" gap="4">
<Heading size="3">{t("agent.metrics")}</Heading>
<AgentCard
agent={agent}
showIpAddress={false}
showHostname={false}
showLastUpdated={false}
></AgentCard>
</Flex>
</Card>
</Grid>
</Box>
<Box>
<Flex align="center" gap="2">
<LapTimerIcon />
<Text as="div" size="2" weight="bold">
{t("agent.uptime")}:
</Text>
<Text as="div" size="2">
{formatUptime(agent)}
</Text>
</Flex>
</Box>
<Box>
<Flex align="center" gap="2">
<ClockIcon />
<Text as="div" size="2" weight="bold">
{t("agent.lastUpdated")}:
</Text>
<Text as="div" size="2">
{formatDateTime(agent.updated_at)}
</Text>
</Flex>
</Box>
{/* 如果存在多个IP地址,展示完整列表 */}
{agent.ip_addresses &&
(() => {
try {
const ipArray = JSON.parse(String(agent.ip_addresses));
if (Array.isArray(ipArray) && ipArray.length > 1) {
return (
<Box pl="6" mt="1">
<Flex direction="column" gap="1">
{ipArray.slice(1).map((ip, index) => (
<Text key={index} size="2" color="gray">
{ip}
</Text>
))}
</Flex>
</Box>
);
}
return null;
} catch (e) {
return null;
}
})()}
</Flex>
</Card>
{/* Agent 资源信息卡片 */}
<Card>
<Flex direction="column" gap="2" className="ml-4">
<Heading size="3">{t("agent.metrics")}</Heading>
<AgentCard
agent={agent}
showIpAddress={false}
showHostname={false}
showLastUpdated={false}
></AgentCard>
</Flex>
</Card>
</Grid>
</Box>
</Container>
);
+22 -13
View File
@@ -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<string, "red" | "green" | "yellow" | "gray"> = {
@@ -52,7 +56,7 @@ const statusColors: Record<string, "red" | "green" | "yellow" | "gray"> = {
const AgentsList = () => {
const navigate = useNavigate();
const [agents, setAgents] = useState<Agent[]>([]);
const [agents, setAgents] = useState<AgentWithLatestMetrics[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 = () => {
<Grid columns={{ initial: "1" }} gap="4">
{agents.map((agent) => (
<Box key={agent.id} className="relative">
<AgentCard agent={agent} />
<AgentStatusBar latestMetric={agent.metrics} agent={agent} />
<Flex gap="2" className="absolute top-4 right-4">
<IconButton
variant="ghost"
+3 -3
View File
@@ -74,7 +74,7 @@ const CreateAgent = () => {
};
return (
<Box>
<Box className="sm:px-6 lg:px-[8%]">
<Flex justify="between" align="center">
<Flex align="center" gap="2">
<Button variant="secondary" onClick={() => navigate("/agents")}>
@@ -83,8 +83,8 @@ const CreateAgent = () => {
<Heading size="6">{t("agent.form.title.create")}</Heading>
</Flex>
</Flex>
<Card>
<Flex direction="column" gap="5">
<Card className="mt-4">
<Flex direction="column" gap="2" className="ml-4">
{/* 提示信息 */}
<Box>
<Flex gap="2">
+52 -45
View File
@@ -100,52 +100,59 @@ const EditAgent = () => {
return (
<Box>
<Flex justify="between" align="center">
<Flex align="center" gap="2">
<Button variant="secondary" onClick={() => navigate(`/agents/${id}`)}>
<ArrowLeftIcon />
</Button>
<Heading size="6">
{t("agent.form.editingClient", { name: formData.name })}
</Heading>
</Flex>
</Flex>
<Card>
<form onSubmit={handleSubmit}>
<Flex direction="column" gap="4">
<Box mb="4">
<Text as="label" size="2" mb="1" weight="bold">
{t("agent.form.name")}{" "}
<Text size="2" color="red">
*
</Text>
</Text>
<TextField.Input
name="name"
value={formData.name}
onChange={handleChange}
placeholder={t("agent.form.namePlaceholder")}
required
/>
<Text size="1" color="gray" mt="1">
{t("agent.form.nameHelp")}
</Text>
</Box>
<Flex justify="end" mt="4" gap="2">
<Button
variant="secondary"
onClick={() => navigate(`/agents/${id}`)}
>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={loading}>
{loading ? t("common.savingChanges") : t("common.saveChanges")}
</Button>
</Flex>
<div className="sm:px-6 lg:px-[8%]">
<Flex justify="between" align="center">
<Flex align="center" gap="2">
<Button
variant="secondary"
onClick={() => navigate(`/agents/${id}`)}
>
<ArrowLeftIcon />
</Button>
<Heading size="6">
{t("agent.form.editingClient", { name: formData.name })}
</Heading>
</Flex>
</form>
</Card>
</Flex>
<Card>
<form onSubmit={handleSubmit}>
<Flex direction="column" gap="2" className="ml-4">
<Box mb="4">
<Text as="label" size="2" mb="1" weight="bold">
{t("agent.form.name")}{" "}
<Text size="2" color="red">
*
</Text>
</Text>
<TextField.Input
name="name"
value={formData.name}
onChange={handleChange}
placeholder={t("agent.form.namePlaceholder")}
required
/>
<Text size="1" color="gray" mt="1">
{t("agent.form.nameHelp")}
</Text>
</Box>
<Flex justify="end" mt="4" gap="2">
<Button
variant="secondary"
onClick={() => navigate(`/agents/${id}`)}
>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={loading}>
{loading
? t("common.savingChanges")
: t("common.saveChanges")}
</Button>
</Flex>
</Flex>
</form>
</Card>
</div>
</Box>
);
};
+205 -227
View File
@@ -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 (
<Box>
<div className="page-container detail-page">
<Flex justify="between" align="center" className="detail-header">
<Flex align="center" gap="2">
<Button variant="secondary" onClick={() => navigate("/monitors")}>
<ArrowLeftIcon />
</Button>
<Heading size="6">{t("monitor.form.title.create")}</Heading>
</Flex>
<Box className="sm:px-6 lg:px-[8%]">
<Flex justify="between" align="center">
<Flex align="center" gap="2">
<Button variant="secondary" onClick={() => navigate("/monitors")}>
<ArrowLeftIcon />
</Button>
<Heading size="6">{t("monitor.form.title.create")}</Heading>
</Flex>
<div className="detail-content">
<Card>
<form onSubmit={handleSubmit}>
<Box pt="2">
<Flex direction="column" gap="4">
<Box>
<Text as="label" size="2">
{t("monitor.form.name")} *
</Text>
<TextField.Input
name="name"
value={formData.name}
onChange={handleChange}
placeholder={t("monitor.form.namePlaceholder")}
required
/>
</Box>
<Box>
<Text as="label" size="2">
URL *
</Text>
<TextField.Input
name="url"
value={formData.url}
onChange={handleChange}
placeholder={t("monitor.form.urlPlaceholder")}
required
/>
</Box>
<Box>
<Text as="label" size="2">
{t("monitor.form.method")} *
</Text>
<Select
name="method"
value={formData.method}
onValueChange={(value) =>
setFormData((prev) => ({ ...prev, method: value }))
}
>
<SelectTrigger />
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="HEAD">HEAD</SelectItem>
</SelectContent>
</Select>
</Box>
<Flex gap="4">
<Box>
<Text as="label" size="2">
{t("monitor.form.interval")} *
</Text>
<TextField.Input
name="interval"
type="number"
value={formData.interval.toString()}
onChange={handleChange}
min="1"
required
/>
<Text size="1" color="gray">
{t("monitor.form.intervalMin")}
</Text>
</Box>
<Box>
<Text as="label" size="2">
{t("monitor.form.timeout")} *
</Text>
<TextField.Input
name="timeout"
type="number"
value={formData.timeout.toString()}
onChange={handleChange}
min="1"
required
/>
</Box>
</Flex>
<Box>
<Text as="label" size="2">
{t("monitor.form.expectedStatus")} *
</Text>
<StatusCodeSelect
value={formData.expectedStatus}
onChange={handleStatusCodeChange}
required
/>
</Box>
<Box>
<Text as="label" size="2">
{t("monitor.form.headers")}
</Text>
<Box>
<Table>
<TableHeader>
<TableRow>
<TableCell>
{t("monitor.form.headerName")}
</TableCell>
<TableCell>
{t("monitor.form.headerValue")}
</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHeader>
<TableBody>
{headers.map((header, index) => (
<TableRow key={index}>
<TableCell>
<TextField.Input
placeholder={t(
"monitor.form.headerNamePlaceholder"
)}
value={header.key}
onChange={(e) =>
handleHeaderChange(
index,
"key",
e.target.value
)
}
/>
</TableCell>
<TableCell>
<TextField.Input
placeholder={t(
"monitor.form.headerValuePlaceholder"
)}
value={header.value}
onChange={(e) =>
handleHeaderChange(
index,
"value",
e.target.value
)
}
/>
</TableCell>
<TableCell>
<IconButton
variant="soft"
color="red"
size="1"
onClick={() => removeHeader(index)}
>
<TrashIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Flex justify="end" mt="2">
<Button
variant="secondary"
onClick={() =>
setHeaders([...headers, { key: "", value: "" }])
}
>
<PlusIcon />
{t("monitor.form.addHeader")}
</Button>
</Flex>
</Box>
<Text size="1" color="gray">
{t("monitor.form.headersHelp")}
</Text>
</Box>
{showBodyField && (
<Box>
<Text as="label" size="2">
{t("monitor.form.body")}
</Text>
<Textarea
name="body"
value={formData.body}
onChange={handleChange}
placeholder={t("monitor.form.bodyPlaceholder")}
/>
</Box>
)}
</Flex>
</Flex>
<Card className="mt-4">
<form onSubmit={handleSubmit}>
<Box pt="2">
<Flex direction="column" gap="2" className="ml-4">
<Box>
<Text as="label" size="2">
{t("monitor.form.name")} *
</Text>
<TextField.Input
name="name"
value={formData.name}
onChange={handleChange}
placeholder={t("monitor.form.namePlaceholder")}
required
/>
</Box>
<Flex justify="end" mt="4" gap="2">
<Button
variant="secondary"
onClick={() => navigate("/monitors")}
<Box>
<Text as="label" size="2">
URL *
</Text>
<TextField.Input
name="url"
value={formData.url}
onChange={handleChange}
placeholder={t("monitor.form.urlPlaceholder")}
required
/>
</Box>
<Box>
<Text as="label" size="2">
{t("monitor.form.method")} *
</Text>
<Select
name="method"
value={formData.method}
onValueChange={(value) =>
setFormData((prev) => ({ ...prev, method: value }))
}
>
{t("monitor.form.cancel")}
</Button>
<Button type="submit" disabled={loading}>
{loading
? t("monitor.form.creating")
: t("monitor.form.create")}
{!loading && <PlusIcon />}
</Button>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="HEAD">HEAD</SelectItem>
</SelectContent>
</Select>
</Box>
<Flex gap="4">
<Box>
<Text as="label" size="2">
{t("monitor.form.interval")} *
</Text>
<TextField.Input
name="interval"
type="number"
value={formData.interval.toString()}
onChange={handleChange}
min="1"
required
/>
<Text size="1" color="gray">
{t("monitor.form.intervalMin")}
</Text>
</Box>
<Box>
<Text as="label" size="2">
{t("monitor.form.timeout")} *
</Text>
<TextField.Input
name="timeout"
type="number"
value={formData.timeout.toString()}
onChange={handleChange}
min="1"
required
/>
</Box>
</Flex>
</form>
</Card>
</div>
</div>
<Box>
<Text as="label" size="2">
{t("monitor.form.expectedStatus")} *
</Text>
<StatusCodeSelect
value={formData.expectedStatus}
onChange={handleStatusCodeChange}
required
/>
</Box>
<Box>
<Text as="label" size="2">
{t("monitor.form.headers")}
</Text>
<Box>
<Table>
<TableHeader>
<TableRow>
<TableCell>{t("monitor.form.headerName")}</TableCell>
<TableCell>{t("monitor.form.headerValue")}</TableCell>
<TableCell></TableCell>
</TableRow>
</TableHeader>
<TableBody>
{headers.map((header, index) => (
<TableRow key={index}>
<TableCell>
<TextField.Input
placeholder={t(
"monitor.form.headerNamePlaceholder"
)}
value={header.key}
onChange={(e) =>
handleHeaderChange(index, "key", e.target.value)
}
/>
</TableCell>
<TableCell>
<TextField.Input
placeholder={t(
"monitor.form.headerValuePlaceholder"
)}
value={header.value}
onChange={(e) =>
handleHeaderChange(
index,
"value",
e.target.value
)
}
/>
</TableCell>
<TableCell>
<IconButton
variant="soft"
color="red"
size="1"
onClick={() => removeHeader(index)}
>
<TrashIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Button
variant="secondary"
onClick={(e) => {
e.preventDefault();
setHeaders([...headers, { key: "", value: "" }]);
}}
className="mt-2"
type="button"
>
<PlusIcon />
{t("monitor.form.addHeader")}
</Button>
</Box>
<Text size="1" color="gray">
{t("monitor.form.headersHelp")}
</Text>
</Box>
{showBodyField && (
<Box>
<Text as="label" size="2">
{t("monitor.form.body")}
</Text>
<Textarea
name="body"
value={formData.body}
onChange={handleChange}
placeholder={t("monitor.form.bodyPlaceholder")}
/>
</Box>
)}
</Flex>
</Box>
<Flex justify="end" gap="2" className="ml-4">
<Button variant="secondary" onClick={() => navigate("/monitors")}>
{t("monitor.form.cancel")}
</Button>
<Button type="submit" disabled={loading}>
{loading ? t("monitor.form.creating") : t("monitor.form.create")}
{!loading && <PlusIcon />}
</Button>
</Flex>
</form>
</Card>
</Box>
);
};
+211 -230
View File
@@ -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 (
<Container size="4">
<div className="page-container detail-page">
<Flex justify="between" align="center" className="detail-header">
<Flex align="center" gap="2">
<Container className="sm:px-6 lg:px-[8%]">
<Flex justify="between" align="center" className="detail-header">
<Flex align="center" gap="2">
<Button
variant="secondary"
onClick={() => navigate(`/monitors/${id}`)}
>
<ArrowLeftIcon />
</Button>
<Heading size="6">
{t("monitor.form.title.edit")}: {formData.name}
</Heading>
</Flex>
</Flex>
<Card className="mt-4">
<form onSubmit={handleSubmit}>
<Box pt="2">
<Flex direction="column" gap="2" className="ml-4">
<Box>
<Text as="label" size="2">
{t("monitor.form.name")} *
</Text>
<TextField.Input
name="name"
value={formData.name}
onChange={handleChange}
placeholder={t("monitor.form.namePlaceholder")}
required
/>
</Box>
<Box>
<Text as="label" size="2">
URL *
</Text>
<TextField.Input
name="url"
value={formData.url}
onChange={handleChange}
placeholder={t("monitor.form.urlPlaceholder")}
required
/>
</Box>
<Box>
<Text as="label" size="2">
{t("monitor.form.method")} *
</Text>
<Select
name="method"
value={formData.method}
onValueChange={(value) =>
setFormData((prev) => ({ ...prev, method: value }))
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="HEAD">HEAD</SelectItem>
</SelectContent>
</Select>
</Box>
<Flex gap="4">
<Box>
<Text as="label" size="2">
{t("monitor.form.interval")} *
</Text>
<TextField.Input
name="interval"
type="number"
value={formData.interval.toString()}
onChange={handleChange}
min="1"
required
/>
<Text size="1" color="gray">
{t("monitor.form.intervalMin")}
</Text>
</Box>
<Box>
<Text as="label" size="2">
{t("monitor.form.timeout")} *
</Text>
<TextField.Input
name="timeout"
type="number"
value={formData.timeout.toString()}
onChange={handleChange}
min="1"
required
/>
</Box>
</Flex>
<Box>
<Text as="label" size="2">
{t("monitor.form.expectedStatus")} *
</Text>
<StatusCodeSelect
value={formData.expectedStatus}
onChange={handleStatusCodeChange}
required
/>
</Box>
<Box>
<Text as="label" size="2">
{t("monitor.form.headers")}
</Text>
<Box>
<Table>
<TableHeader>
<TableRow>
<TableCell>{t("monitor.form.headerName")}</TableCell>
<TableCell>{t("monitor.form.headerValue")}</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{headers.map((header, index) => (
<TableRow key={index}>
<TableCell>
<TextField.Input
placeholder={t(
"monitor.form.headerNamePlaceholder"
)}
value={header.key}
onChange={(e) =>
handleHeaderChange(index, "key", e.target.value)
}
/>
</TableCell>
<TableCell>
<TextField.Input
placeholder={t(
"monitor.form.headerValuePlaceholder"
)}
value={header.value}
onChange={(e) =>
handleHeaderChange(
index,
"value",
e.target.value
)
}
/>
</TableCell>
<TableCell>
<IconButton
variant="soft"
color="red"
size="1"
onClick={() => removeHeader(index)}
>
<TrashIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Flex justify="end" mt="2">
<Button
variant="secondary"
onClick={(e) => {
e.preventDefault();
setHeaders([...headers, { key: "", value: "" }]);
}}
type="button"
>
<PlusIcon />
{t("monitor.form.addHeader")}
</Button>
</Flex>
</Box>
<Text size="1" color="gray">
{t("monitor.form.headersHelp")}
</Text>
</Box>
{showBodyField && (
<Box>
<Text as="label" size="2">
{t("monitor.form.body")}
</Text>
<Textarea
name="body"
value={formData.body}
onChange={handleChange}
placeholder={t("monitor.form.bodyPlaceholder")}
/>
</Box>
)}
</Flex>
</Box>
<Flex justify="end" mt="4" gap="2">
<Button
variant="secondary"
onClick={() => navigate(`/monitors/${id}`)}
>
<ArrowLeftIcon />
{t("monitor.form.cancel")}
</Button>
<Button type="submit" disabled={loading}>
{loading ? t("common.savingChanges") : t("common.saveChanges")}
{!loading && <UpdateIcon />}
</Button>
<Heading size="6">
{t("monitor.form.title.edit")}: {formData.name}
</Heading>
</Flex>
</Flex>
<div className="detail-content">
<Card>
<form onSubmit={handleSubmit}>
<Box pt="2">
<Flex direction="column" gap="4">
<Box>
<Text as="label" size="2">
{t("monitor.form.name")} *
</Text>
<TextField.Input
name="name"
value={formData.name}
onChange={handleChange}
placeholder={t("monitor.form.namePlaceholder")}
required
/>
</Box>
<Box>
<Text as="label" size="2">
URL *
</Text>
<TextField.Input
name="url"
value={formData.url}
onChange={handleChange}
placeholder={t("monitor.form.urlPlaceholder")}
required
/>
</Box>
<Box>
<Text as="label" size="2">
{t("monitor.form.method")} *
</Text>
<Select
name="method"
value={formData.method}
onValueChange={(value) =>
setFormData((prev) => ({ ...prev, method: value }))
}
>
<SelectTrigger />
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
<SelectItem value="HEAD">HEAD</SelectItem>
</SelectContent>
</Select>
</Box>
<Flex gap="4">
<Box>
<Text as="label" size="2">
{t("monitor.form.interval")} *
</Text>
<TextField.Input
name="interval"
type="number"
value={formData.interval.toString()}
onChange={handleChange}
min="1"
required
/>
<Text size="1" color="gray">
{t("monitor.form.intervalMin")}
</Text>
</Box>
<Box>
<Text as="label" size="2">
{t("monitor.form.timeout")} *
</Text>
<TextField.Input
name="timeout"
type="number"
value={formData.timeout.toString()}
onChange={handleChange}
min="1"
required
/>
</Box>
</Flex>
<Box>
<Text as="label" size="2">
{t("monitor.form.expectedStatus")} *
</Text>
<StatusCodeSelect
value={formData.expectedStatus}
onChange={handleStatusCodeChange}
required
/>
</Box>
<Box>
<Text as="label" size="2">
{t("monitor.form.headers")}
</Text>
<Box>
<Table>
<TableHeader>
<TableRow>
<TableCell>
{t("monitor.form.headerName")}
</TableCell>
<TableCell>
{t("monitor.form.headerValue")}
</TableCell>
</TableRow>
</TableHeader>
<TableBody>
{headers.map((header, index) => (
<TableRow key={index}>
<TableCell>
<TextField.Input
placeholder={t(
"monitor.form.headerNamePlaceholder"
)}
value={header.key}
onChange={(e) =>
handleHeaderChange(
index,
"key",
e.target.value
)
}
/>
</TableCell>
<TableCell>
<TextField.Input
placeholder={t(
"monitor.form.headerValuePlaceholder"
)}
value={header.value}
onChange={(e) =>
handleHeaderChange(
index,
"value",
e.target.value
)
}
/>
</TableCell>
<TableCell>
<IconButton
variant="soft"
color="red"
size="1"
onClick={() => removeHeader(index)}
>
<TrashIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Flex justify="end" mt="2">
<Button
variant="secondary"
onClick={() =>
setHeaders([...headers, { key: "", value: "" }])
}
>
<PlusIcon />
{t("monitor.form.addHeader")}
</Button>
</Flex>
</Box>
<Text size="1" color="gray">
{t("monitor.form.headersHelp")}
</Text>
</Box>
{showBodyField && (
<Box>
<Text as="label" size="2">
{t("monitor.form.body")}
</Text>
<Textarea
name="body"
value={formData.body}
onChange={handleChange}
placeholder={t("monitor.form.bodyPlaceholder")}
/>
</Box>
)}
</Flex>
</Box>
<Flex justify="end" mt="4" gap="2">
<Button
variant="secondary"
onClick={() => navigate(`/monitors/${id}`)}
>
{t("monitor.form.cancel")}
</Button>
<Button type="submit" disabled={loading}>
{loading
? t("common.savingChanges")
: t("common.saveChanges")}
{!loading && <UpdateIcon />}
</Button>
</Flex>
</form>
</Card>
</div>
</div>
</form>
</Card>
</Container>
);
};
+87 -94
View File
@@ -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 (
<Container size="4">
<Flex justify="between" align="center" className="detail-header">
<Flex align="center" gap="2">
<Button variant="secondary" onClick={() => navigate("/monitors")}>
<ArrowLeftIcon />
</Button>
<Heading size="6">{monitor.name}</Heading>
<Badge color={statusColors[monitor.status]}>
{monitor.status === "up"
? t("monitor.status.normal")
: monitor.status === "down"
? t("monitor.status.failure")
: t("monitor.status.pending")}
</Badge>
</Flex>
<Flex gap="2">
<Button variant="secondary" onClick={handleCheck}>
<ReloadIcon />
{t("monitor.manualCheck")}
</Button>
<Button
variant="secondary"
onClick={() => navigate(`/monitors/edit/${id}`)}
>
<Pencil1Icon />
{t("monitor.edit")}
</Button>
<Button variant="secondary" onClick={handleDelete}>
<TrashIcon />
{t("monitor.delete")}
</Button>
</Flex>
<Container className="sm:px-6 lg:px-[8%]">
<Flex justify="between" align="center">
<Flex align="center" gap="2">
<Button variant="secondary" onClick={() => navigate("/monitors")}>
<ArrowLeftIcon />
</Button>
<Heading size="6">{monitor.name}</Heading>
<Badge color={statusColors[monitor.status]}>
{monitor.status === "up"
? t("monitor.status.normal")
: monitor.status === "down"
? t("monitor.status.failure")
: t("monitor.status.pending")}
</Badge>
</Flex>
<Box pt="4" className="detail-content">
<Card>
<Flex direction="column" gap="3">
<Heading size="4">{t("monitor.detailInfo")}</Heading>
<Grid columns="2" gap="3">
<Text>URL:</Text>
<Text>{monitor.url}</Text>
<Text>{t("monitor.method")}:</Text>
<Text>{monitor.method}</Text>
<Text>{t("monitor.interval")}:</Text>
<Text>
{monitor.interval} {t("common.seconds")}
</Text>
<Text>{t("monitor.timeout")}:</Text>
<Text>
{monitor.timeout} {t("common.seconds")}
</Text>
<Text>{t("monitor.expectedStatus")}:</Text>
<Text>{formatStatusCode(monitor.expected_status)}</Text>
<Text>{t("monitor.createTime")}:</Text>
<Text>{new Date(monitor.created_at).toLocaleString()}</Text>
<Text>{t("monitor.headers")}:</Text>
<Text style={{ overflowWrap: "break-word" }}>
{typeof monitor.headers === "string"
? monitor.headers
: JSON.stringify(monitor.headers)}
</Text>
<Text>{t("monitor.body")}:</Text>
<Text style={{ overflowWrap: "break-word" }}>
{monitor.body || "-"}
</Text>
</Grid>
</Flex>
</Card>
{/* 添加响应时间图表 */}
<Card>
<Flex direction="column" gap="3" mt="4">
<Heading size="4">{t("monitor.oneDayHistory")}</Heading>
<Box>
<ResponseTimeChart
history={monitor.history || []}
height={220}
/>
</Box>
</Flex>
</Card>
<Card>
<Flex direction="column" gap="3">
<Heading size="4">{t("monitor.MonthsHistory")}</Heading>
<Box>
<StatusBar dailyStats={monitor.dailyStats || []} />
</Box>
</Flex>
</Card>
</Box>
<Flex gap="2">
<Button variant="secondary" onClick={handleCheck}>
<ReloadIcon />
{t("monitor.manualCheck")}
</Button>
<Button
variant="secondary"
onClick={() => navigate(`/monitors/edit/${id}`)}
>
<Pencil1Icon />
{t("monitor.edit")}
</Button>
<Button variant="secondary" onClick={handleDelete}>
<TrashIcon />
{t("monitor.delete")}
</Button>
</Flex>
</Flex>
<Box pt="4" className="detail-content">
<Card>
<Flex direction="column" gap="2" className="ml-4">
<Heading size="4">{t("monitor.detailInfo")}</Heading>
<Grid columns="2" gap="3">
<Text>URL:</Text>
<Text>{monitor.url}</Text>
<Text>{t("monitor.method")}:</Text>
<Text>{monitor.method}</Text>
<Text>{t("monitor.interval")}:</Text>
<Text>
{monitor.interval} {t("common.seconds")}
</Text>
<Text>{t("monitor.timeout")}:</Text>
<Text>
{monitor.timeout} {t("common.seconds")}
</Text>
<Text>{t("monitor.expectedStatus")}:</Text>
<Text>{formatStatusCode(monitor.expected_status)}</Text>
<Text>{t("monitor.createTime")}:</Text>
<Text>{new Date(monitor.created_at).toLocaleString()}</Text>
<Text>{t("monitor.headers")}:</Text>
<Text style={{ overflowWrap: "break-word" }}>
{typeof monitor.headers === "string"
? monitor.headers
: JSON.stringify(monitor.headers)}
</Text>
<Text>{t("monitor.body")}:</Text>
<Text style={{ overflowWrap: "break-word" }}>
{monitor.body || "-"}
</Text>
</Grid>
</Flex>
</Card>
{/* 添加响应时间图表 */}
<Card>
<Flex direction="column" gap="2" className="ml-4">
<Heading size="4">{t("monitor.oneDayHistory")}</Heading>
<Box>
<ResponseTimeChart history={monitor.history || []} height={220} />
</Box>
</Flex>
</Card>
<Card>
<Flex direction="column" gap="2" className="ml-4">
<Heading size="4">{t("monitor.MonthsHistory")}</Heading>
<Box>
<StatusBar dailyStats={monitor.dailyStats || []} />
</Box>
</Flex>
</Card>
</Box>
</Container>
);
};
+1 -1
View File
@@ -199,7 +199,7 @@ const MonitorsList = () => {
}
return (
<Container size="4">
<Container className="sm:px-6 lg:px-[8%]">
<Flex justify="between" align="center">
<Heading size="6">{t("monitors.pageTitle")}</Heading>
<Flex className="mt-4 space-x-2">
+100 -48
View File
@@ -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<string | null>(null);
const [selectedAgent, setSelectedAgent] =
useState<AgentWithLatestMetrics | null>(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 (
<Theme appearance="light">
<Box>
<div className="page-container">
<div className="page-container sm:px-6 lg:px-[8%]">
{/* 状态页标题区域 */}
<Flex
direction="column"
@@ -116,13 +146,51 @@ const StatusPage = () => {
<Text size="5" align="center">
{pageDescription}
</Text>
<Flex gap="2" mt="2">
<Badge>
{t("statusPage.lastUpdated")}: {t("statusPage.justNow")}
</Badge>
</Flex>
</Flex>
{/* 客户端监控状态 */}
{data.agents.length > 0 && (
<Box py="6">
<Heading size="5" mb="4">
{t("statusPage.agentStatus")}
</Heading>
<div className="grid grid-cols-1 gap-4">
{data.agents.map((agent) => (
<div key={agent.id}>
<div
className="cursor-pointer transition hover:scale-[1.01]"
onClick={() => handleAgentClick(agent)}
>
<AgentStatusBar
latestMetric={agent.metrics}
agent={agent as any}
/>
</div>
{/* 展开的详情区域 */}
{selectedAgent?.id === agent.id && (
<div className="mt-4">
{cardLoading ? (
<div className="flex items-center justify-center h-40">
<span className="text-lg text-gray-500">
{t("common.loading")}
</span>
</div>
) : (
<AgentCard
agent={{
...selectedAgent,
metrics: selectedAgentMetrics || [],
}}
/>
)}
</div>
)}
</div>
))}
</div>
</Box>
)}
{/* API服务状态 */}
{data.monitors.length > 0 && (
<Box py="6">
@@ -131,23 +199,7 @@ const StatusPage = () => {
</Heading>
<Grid columns={{ initial: "1" }} gap="4">
{data.monitors.map((monitor) => (
<MonitorCard monitor={monitor} />
))}
</Grid>
</Box>
)}
{/* 客户端监控状态 */}
{data.agents.length > 0 && (
<Box py="6">
<Heading size="5" mb="4">
{t("statusPage.agentStatus")}
</Heading>
<Grid columns={{ initial: "1" }} gap="4">
{data.agents.map((agent) => (
<Box key={agent.id}>
<AgentCard agent={agent} showIpAddress={false} />
</Box>
<MonitorCard monitor={monitor} key={monitor.id} />
))}
</Grid>
</Box>
+13
View File
@@ -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;