feat: add agent_status_bar and optimism style
This commit is contained in:
1 parent
2a9e6c06da
commit
0644f0d455
21 files changed
+1174
-917
No files matched your search
+20
-13
@@ -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 };
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
File renamed without changes.
@@ -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
@@ -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": "监控项状态变更",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user