diff --git a/src/web/Dockerfile b/src/web/Dockerfile
new file mode 100644
index 0000000..0e761ee
--- /dev/null
+++ b/src/web/Dockerfile
@@ -0,0 +1,36 @@
+# ---- 1. 构建阶段 ----
+FROM node:20-alpine AS build
+
+# 设置工作目录
+WORKDIR /app
+
+# 复制依赖清单
+COPY package*.json ./
+
+# 安装依赖
+RUN npm install
+
+# 复制全部源码
+COPY . .
+
+# 构建生产环境代码
+RUN npm run build
+
+
+# ---- 2. 部署阶段 ----
+FROM nginx:alpine
+
+# 删除默认配置
+RUN rm /etc/nginx/conf.d/default.conf
+
+# 复制你自己的 nginx 配置
+COPY build_tools/front_end/nginx.conf /etc/nginx/conf.d/default.conf
+
+# 将打包好的 dist 文件放到 nginx 的静态目录
+COPY --from=build /app/dist /usr/share/nginx/html
+
+# 暴露 80 端口
+EXPOSE 80
+
+# 启动 Nginx
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/src/web/app.py b/src/web/app.py
deleted file mode 100644
index 5b4dd28..0000000
--- a/src/web/app.py
+++ /dev/null
@@ -1,39 +0,0 @@
-from flask import Flask, jsonify
-import requests
-
-app = Flask(__name__)
-
-EXTERNAL_APIS = {
- "nodes": "http://master.argus.com/api/v1/nodes",
- "alerts": "http://alertmanager.argus.com/api/v2/alerts"
-}
-
-
-def fetch_external(api_name):
- """通用的外部接口请求函数"""
- try:
- url = EXTERNAL_APIS[api_name]
- response = requests.get(url, timeout=5)
- response.raise_for_status()
- return {"status": "success", "data": response.json()}
- except requests.exceptions.RequestException as e:
- return {"status": "error", "message": str(e)}
-
-
-@app.route("/api/v1/web/health", methods=["GET"])
-def get_health():
- return jsonify(fetch_external("health"))
-
-
-@app.route("/api/v1/web/nodes", methods=["GET"])
-def get_nodes():
- return jsonify(fetch_external("nodes"))
-
-
-@app.route("/api/v1/web/alerts", methods=["GET"])
-def get_alerts():
- return jsonify(fetch_external("alerts"))
-
-
-if __name__ == "__main__":
- app.run(host="0.0.0.0", port=5000, debug=True)
diff --git a/src/web/build_tools/front_end/build.sh b/src/web/build_tools/front_end/build.sh
new file mode 100644
index 0000000..20ba270
--- /dev/null
+++ b/src/web/build_tools/front_end/build.sh
@@ -0,0 +1,5 @@
+docker pull node:20-alpine
+docker pull nginx:alpine
+cd ../..
+docker build -t portal-frontend .
+rm -f portal-frontend.tar.gz && sudo docker image save portal-frontend:latest | gzip > portal-frontend.tar.gz
\ No newline at end of file
diff --git a/src/web/build_tools/front_end/nginx.conf b/src/web/build_tools/front_end/nginx.conf
new file mode 100644
index 0000000..0c3b3bc
--- /dev/null
+++ b/src/web/build_tools/front_end/nginx.conf
@@ -0,0 +1,13 @@
+server {
+ listen 80;
+ server_name web.argus.com;
+
+ root /usr/share/nginx/html;
+ index index.html;
+
+ # React 前端路由兼容
+ location / {
+ try_files $uri /index.html;
+ }
+
+}
diff --git a/src/web/build_tools/proxy/Dockerfile b/src/web/build_tools/proxy/Dockerfile
new file mode 100644
index 0000000..7162426
--- /dev/null
+++ b/src/web/build_tools/proxy/Dockerfile
@@ -0,0 +1,17 @@
+# 使用轻量级 Nginx 基础镜像
+FROM nginx:1.25-alpine
+
+# 删除默认配置
+RUN rm -rf /etc/nginx/conf.d/*
+
+# 复制自定义 Proxy 配置
+# (可以在构建时直接COPY进去,也可以运行时挂载)
+COPY conf.d/ /etc/nginx/conf.d/
+
+# 日志目录(可选)
+VOLUME ["/var/log/nginx"]
+
+# 暴露端口
+EXPOSE 80 443
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/src/web/build_tools/proxy/argus-proxy.tar.gz b/src/web/build_tools/proxy/argus-proxy.tar.gz
new file mode 100644
index 0000000..c46a22f
Binary files /dev/null and b/src/web/build_tools/proxy/argus-proxy.tar.gz differ
diff --git a/src/web/build_tools/proxy/argus.nginx.conf b/src/web/build_tools/proxy/argus.nginx.conf
new file mode 100644
index 0000000..0316133
--- /dev/null
+++ b/src/web/build_tools/proxy/argus.nginx.conf
@@ -0,0 +1,71 @@
+# 门户前端 (React 静态资源通过内网 Nginx 或 Node.js 服务暴露)
+server {
+ listen 80;
+ server_name web.argus.com;
+
+ location / {
+ proxy_pass http://web.argus.com; # 门户前端内部服务
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+}
+
+# Grafana
+server {
+ listen 80;
+ server_name grafana.metric.argus.com;
+
+ location / {
+ proxy_pass http://grafana.metric.argus.com;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+}
+
+# Prometheus
+server {
+ listen 80;
+ server_name prometheus.metric.argus.com;
+
+ location / {
+ proxy_pass http://prometheus.metric.argus.com;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+}
+
+# Elasticsearch
+server {
+ listen 80;
+ server_name es.log.argus.com;
+
+ location / {
+ proxy_pass http://es.log.argus.com;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+}
+
+# Kibana
+server {
+ listen 80;
+ server_name kibana.log.argus.com;
+
+ location / {
+ proxy_pass http://kibana.log.argus.com;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+}
+
+# Alertmanager
+server {
+ listen 80;
+ server_name alertmanager.alert.argus.com;
+
+ location / {
+ proxy_pass http://alertmanager.alert.argus.com;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ }
+}
diff --git a/src/web/build_tools/proxy/build_image.sh b/src/web/build_tools/proxy/build_image.sh
new file mode 100644
index 0000000..78f1acb
--- /dev/null
+++ b/src/web/build_tools/proxy/build_image.sh
@@ -0,0 +1,2 @@
+docker build -t argus-proxy:latest .
+rm -f argus-proxy.tar.gz && sudo docker image save argus-proxy:latest | gzip > argus-proxy.tar.gz
diff --git a/src/web/build_tools/proxy/conf.d/argus.conf b/src/web/build_tools/proxy/conf.d/argus.conf
new file mode 100644
index 0000000..786c293
--- /dev/null
+++ b/src/web/build_tools/proxy/conf.d/argus.conf
@@ -0,0 +1,78 @@
+server {
+ listen 80;
+ server_name web.argus.com;
+
+ # 门户网站(React 前端),通过 proxy 转发到内部服务
+ location / {
+ proxy_pass http://portal-frontend:80;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+server {
+ listen 80;
+ server_name grafana.metric.argus.com;
+
+ location / {
+ proxy_pass http://grafana.metric.argus.com;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+server {
+ listen 80;
+ server_name prometheus.metric.argus.com;
+
+ location / {
+ proxy_pass http://prometheus.metric.argus.com;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+server {
+ listen 80;
+ server_name es.log.argus.com;
+
+ location / {
+ proxy_pass http://es.log.argus.com;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+server {
+ listen 80;
+ server_name kibana.log.argus.com;
+
+ location / {
+ proxy_pass http://kibana.log.argus.com;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
+
+server {
+ listen 80;
+ server_name alertmanager.alert.argus.com;
+
+ location / {
+ proxy_pass http://alertmanager.alert.argus.com;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ }
+}
diff --git a/src/web/portal-frontend.tar.gz b/src/web/portal-frontend.tar.gz
new file mode 100644
index 0000000..281203a
Binary files /dev/null and b/src/web/portal-frontend.tar.gz differ
diff --git a/src/web/src/components/AlertCard.jsx b/src/web/src/components/AlertCard.jsx
deleted file mode 100644
index 5e06cac..0000000
--- a/src/web/src/components/AlertCard.jsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import { Card, Group, Text, Anchor } from "@mantine/core";
-
-export function AlertCard({ alerts, link }) {
- const alertTypes = [
- { label: "严重告警", count: alerts.critical_alerts, color: "red" },
- { label: "主要告警", count: alerts.major_alerts, color: "yellow" },
- { label: "次要告警", count: alerts.minor_alerts, color: "blue" },
- ];
-
- return (
-
- 告警监控
-
-
- {alerts.total_alerts} 待处理告警
-
-
- {alertTypes.map((a) => (
-
-
-
- {a.label}
-
- {a.count}
-
- ))}
-
- );
-}
diff --git a/src/web/src/components/AlertFilters.jsx b/src/web/src/components/AlertFilters.jsx
new file mode 100644
index 0000000..73a8a6f
--- /dev/null
+++ b/src/web/src/components/AlertFilters.jsx
@@ -0,0 +1,38 @@
+import { Group, Select } from "@mantine/core";
+
+export function AlertFilters({ filters, setFilters, nodeOptions }) {
+ return (
+
+
+ );
+}
diff --git a/src/web/src/components/AlertStats.jsx b/src/web/src/components/AlertStats.jsx
new file mode 100644
index 0000000..9315975
--- /dev/null
+++ b/src/web/src/components/AlertStats.jsx
@@ -0,0 +1,47 @@
+import { Card, Group, Text, Badge, Stack, Anchor } from "@mantine/core";
+import { Link } from "react-router-dom";
+
+export function AlertStats({ stats, layout = "row", title, link }) {
+ const Wrapper = layout === "row" ? Group : Stack;
+
+ return (
+
+ {(title || link) && (
+
+ {title && {title}}
+ {link && (
+
+ 查看更多
+
+ )}
+
+ )}
+
+
+
+ ●
+ 总数
+ {stats.total || 0}
+
+
+
+ ●
+ 严重
+ {stats.critical || 0}
+
+
+
+ ●
+ 警告
+ {stats.warning || 0}
+
+
+
+ ●
+ 信息
+ {stats.info || 0}
+
+
+
+ );
+}
diff --git a/src/web/src/components/AlertTable.jsx b/src/web/src/components/AlertTable.jsx
new file mode 100644
index 0000000..9bc2219
--- /dev/null
+++ b/src/web/src/components/AlertTable.jsx
@@ -0,0 +1,96 @@
+import { Table, Group, ActionIcon, Button } from "@mantine/core";
+import { IconChevronUp, IconChevronDown } from "@tabler/icons-react";
+
+export function AlertTable({
+ alerts,
+ paginatedAlerts,
+ page,
+ setPage,
+ pageSize,
+ sortedAlerts,
+ sortConfig,
+ handleSort,
+ getRowColor,
+ getSeverityColor,
+ getStateBadge,
+ formatRelativeTime,
+}) {
+ const totalPages = Math.ceil(sortedAlerts.length / pageSize);
+
+ return (
+ <>
+
+
+
+ {[
+ { key: "alertname", label: "名称" },
+ { key: "instance", label: "节点" },
+ { key: "severity", label: "严重性" },
+ { key: "state", label: "状态" },
+ { key: "startsAt", label: "开始时间" },
+ { key: "endsAt", label: "结束时间" },
+ { key: "updatedAt", label: "更新时间" },
+ { key: "summary", label: "描述" },
+ ].map((col) => (
+
+
+ {col.label}
+ {["severity", "startsAt", "instance"].includes(col.key) && (
+ handleSort(col.key)}>
+ {sortConfig.key === col.key && sortConfig.direction === "asc" ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ ))}
+
+
+
+ {paginatedAlerts.map((alert, i) => (
+
+ {alert.labels?.alertname || "-"}
+ {alert.labels?.instance || "-"}
+
+ {alert.labels?.severity || "info"}
+
+ {getStateBadge(alert.status?.state)}
+ {formatRelativeTime(alert.startsAt)}
+
+ {alert.endsAt ? new Date(alert.endsAt).toLocaleString() : "-"}
+
+ {formatRelativeTime(alert.updatedAt)}
+ {alert.annotations?.summary || "-"}
+
+ ))}
+
+
+
+ {/* 分页控件 */}
+
+
+
+ {page} / {totalPages}
+
+
+
+ >
+ );
+}
diff --git a/src/web/src/components/DashboardNodeTable.jsx b/src/web/src/components/DashboardNodeTable.jsx
deleted file mode 100644
index 933db4b..0000000
--- a/src/web/src/components/DashboardNodeTable.jsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import { Card, Group, Table, Text } from "@mantine/core";
-import NodeStatus from "../components/NodeStatus";
-import { Link } from "react-router-dom";
-
-export function ClusterTable({ cluster }) {
- const rows = cluster.nodes.map((n) => {
-
- return (
-
- {n.id}
- {n.name}
-
-
-
-
- 80 ? "red" : "blue"}>{n.load}%
-
-
- );
- });
-
- return (
-
-
- 集群节点
- {/* 链接到 /nodeInfo 页面 */}
-
-
- {cluster.total_nodes} 个节点
-
-
-
-
-
-
- 节点ID
- 节点名称
- 状态
- 负载
-
-
- {rows}
-
-
- );
-}
diff --git a/src/web/src/components/HealthCard.jsx b/src/web/src/components/HealthCard.jsx
index 0bbadb5..35bba4d 100644
--- a/src/web/src/components/HealthCard.jsx
+++ b/src/web/src/components/HealthCard.jsx
@@ -1,37 +1,60 @@
import { Card, Group, Text, RingProgress } from "@mantine/core";
+// 给一些常见状态定义颜色,没定义的就用 gray
+const statusColors = {
+ healthy: "green",
+ warning: "yellow",
+ error: "red",
+ online: "green",
+ offline: "gray",
+};
+
export function HealthCard({ health }) {
- const totalNodes = health.healthy_nodes + health.warning_nodes + health.error_nodes;
+ const totalNodes = health?.total || 0;
+ const stats = health?.status_statistics || [];
+
+ // 计算环形图 sections
+ const sections = stats.map((s) => ({
+ value: (s.count / totalNodes) * 100,
+ color: statusColors[s.status] || "gray",
+ }));
+
+ // 计算一个主展示百分比(这里沿用原来的逻辑,用 online 或 healthy 优先)
+ const mainStatus = stats.find(
+ (s) => s.status === "online" || s.status === "healthy"
+ );
+ const mainPercent = mainStatus
+ ? ((mainStatus.count / totalNodes) * 100).toFixed(1)
+ : "0.0";
return (
- 健康状态
+ 节点健康状态
{(health.healthy_nodes / totalNodes * 100).toFixed(1)}%}
+ sections={sections}
+ label={
+
+ {mainPercent}%
+
+ }
/>
-
- 健康节点
- {health.healthy_nodes}
-
-
- 警告节点
- {health.warning_nodes}
-
-
- 故障节点
- {health.error_nodes}
-
+ {stats.map((s, idx) => (
+
+
+ {s.status}
+
+ {s.count}
+
+ ))}
diff --git a/src/web/src/components/NodeConfigCard.jsx b/src/web/src/components/NodeConfigCard.jsx
index 48933f1..6282aa0 100644
--- a/src/web/src/components/NodeConfigCard.jsx
+++ b/src/web/src/components/NodeConfigCard.jsx
@@ -1,93 +1,132 @@
-import { useState } from "react";
-import {
- Card,
- Text,
- Group,
- Button,
- TextInput,
- Stack,
- ActionIcon,
- Select,
-} from "@mantine/core";
-import { IconEdit, IconX, IconCheck } from "@tabler/icons-react"; // 图标
+import { useState, useEffect } from "react";
+import { Card, Text, Group, TextInput, Stack, ActionIcon } from "@mantine/core";
+import { IconEdit, IconX, IconCheck, IconPlus, IconTrash } from "@tabler/icons-react";
import { apiRequest } from "../config/request";
import { EXTERNAL_API } from "../config/api";
-export default function NodeConfigCard({ node, onSaved }) {
- const [editing, setEditing] = useState(false);
- const [config, setConfig] = useState(node.config || {});
- const [saving, setSaving] = useState(false);
+export default function NodeConfigCard({ nodeId, config = {}, onSaved }) {
+ const [editing, setEditing] = useState(false);
+ const [configList, setConfigList] = useState([]);
+ const [newKey, setNewKey] = useState("");
+ const [newValue, setNewValue] = useState("");
+ const [saving, setSaving] = useState(false);
- const handleSave = async () => {
- setSaving(true);
- try {
- await apiRequest(`${EXTERNAL_API.MASTER_NODES}/${node.id}`, {
- method: "PUT",
- body: JSON.stringify({ config }),
- }, "配置已更新"); // 成功提示
+ useEffect(() => {
+ const arr = Object.entries(config || {});
+ setConfigList(arr);
+ }, [config]);
- setEditing(false);
- onSaved && onSaved(); // 可选回调刷新详情
- } catch (err) {
- console.error("更新配置失败", err);
- } finally {
- setSaving(false);
- }
- };
+ const removeConfig = (index) => {
+ setConfigList((prev) => prev.filter((_, i) => i !== index));
+ };
- return (
-
-
- 配置信息
-
- {editing ? (
- <>
-
-
-
- setEditing(false)}>
-
-
- >
- ) : (
- setEditing(true)}>
-
-
- )}
-
-
-
- {editing ? (
-
-
- setConfig({ ...config, max_connections: e.target.value })
- }
- />
-
- setConfig({ ...config, timeout: e.target.value })
- }
- />
- {/* 日志级别下拉框 */}
- setConfig({ ...config, log_level: value })}
- />
-
- ) : (
-
- 最大连接数: {config.max_connections}
- 超时时间: {config.timeout}
- 日志级别: {config.log_level}
-
- )}
-
+ const updateConfig = (index, key, value) => {
+ setConfigList((prev) =>
+ prev.map((item, i) => (i === index ? [key, value] : item))
);
+ };
+
+ const addConfig = () => {
+ if (newKey && !configList.find(([k]) => k === newKey)) {
+ setConfigList((prev) => [...prev, [newKey, newValue]]);
+ setNewKey("");
+ setNewValue("");
+ }
+ };
+
+ const handleSave = async () => {
+ setSaving(true);
+ try {
+ const configObj = Object.fromEntries(configList);
+ await apiRequest(`${EXTERNAL_API.MASTER_NODES}/${nodeId}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ config: configObj }),
+ });
+ setEditing(false);
+ onSaved && onSaved();
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ return (
+
+
+ 配置信息
+
+ {editing ? (
+ <>
+
+
+
+ setEditing(false)}>
+
+
+ >
+ ) : (
+ setEditing(true)}>
+
+
+ )}
+
+
+
+ {editing ? (
+
+ {configList.map(([key, value], idx) => (
+
+ updateConfig(idx, e.target.value, value)}
+ />
+ updateConfig(idx, key, e.target.value)}
+ />
+ removeConfig(idx)}>
+
+
+
+ ))}
+
+ setNewKey(e.target.value)}
+ />
+ setNewValue(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && addConfig()}
+ />
+
+
+
+
+
+ ) : (
+
+ {configList.length > 0 ? (
+ configList.map(([key, value], idx) => (
+
+ {key}:
+ {String(value)}
+
+ ))
+ ) : (
+ 暂无配置
+ )}
+
+ )}
+
+ );
}
diff --git a/src/web/src/components/NodeDetailDrawer.jsx b/src/web/src/components/NodeDetailDrawer.jsx
index 072d12e..4884e6e 100644
--- a/src/web/src/components/NodeDetailDrawer.jsx
+++ b/src/web/src/components/NodeDetailDrawer.jsx
@@ -6,16 +6,14 @@ import {
Center,
ScrollArea,
Group,
- Badge,
- Stack,
Divider,
ThemeIcon,
+ Stack,
} from "@mantine/core";
import { healthStatus } from "../config/status";
import { apiRequest } from "../config/request";
import { EXTERNAL_API } from "../config/api";
-// 子组件
import NodeConfigCard from "./NodeConfigCard";
import NodeLabelCard from "./NodeLabelCard";
import NodeMetaCard from "./NodeMetaCard";
@@ -25,7 +23,6 @@ export default function NodeDetailDrawer({ opened, nodeId, onClose }) {
const [node, setNode] = useState(null);
const [loading, setLoading] = useState(false);
- // 获取节点详情
const fetchNodeDetail = async () => {
if (!nodeId) return;
setLoading(true);
@@ -38,9 +35,7 @@ export default function NodeDetailDrawer({ opened, nodeId, onClose }) {
};
useEffect(() => {
- if (opened && nodeId) {
- fetchNodeDetail();
- }
+ if (opened && nodeId) fetchNodeDetail();
}, [opened, nodeId]);
return (
@@ -54,52 +49,69 @@ export default function NodeDetailDrawer({ opened, nodeId, onClose }) {
overlayProps={{ backgroundOpacity: 0.4, blur: 4 }}
>
{loading ? (
-
-
-
-) : node ? (
-
- {/* 固定头部基础信息 */}
-
-
-
- {healthStatus(node.status).icon}
-
+
+
+
+ ) : node ? (
+
+ {/* 固定头部基础信息 */}
+
+
+
+ {healthStatus(node.status).icon}
+
- {node.name}
- {node.type}
-
- {node.status}
-
-
-
-
+
+ {node.name}
+
+
{node.type}
+
{node.status}
+
+ 最后更新时间: {new Date(node.last_updated).toLocaleString()}
+
+
+
+
- {/* 滚动内容 */}
-
-
-
-
-
-
+ {/* 滚动内容 */}
+
+
+ {/* 配置信息 */}
+
-
- 最后更新时间: {new Date(node.last_updated).toLocaleString()}
-
-
-
-
-) : (
-
暂无数据
-)}
+ {/* 标签信息 */}
+
+
+ {/* 元数据 */}
+
+
+ {/* 健康信息 */}
+
+
+ {/* 其他基础信息展示 */}
+
+ 注册时间: {new Date(node.register_time).toLocaleString()}
+ 最近上报时间: {new Date(node.last_report).toLocaleString()}
+
+
+
+
+ ) : (
+ 暂无数据
+ )}
);
}
diff --git a/src/web/src/components/NodeHealthCard.jsx b/src/web/src/components/NodeHealthCard.jsx
index c80aa1b..0186772 100644
--- a/src/web/src/components/NodeHealthCard.jsx
+++ b/src/web/src/components/NodeHealthCard.jsx
@@ -1,39 +1,14 @@
-import { Card, Text, Group, Stack, Badge } from "@mantine/core";
-import { IconCheck, IconAlertTriangle, IconX } from "@tabler/icons-react";
-
-// 状态映射:颜色 + 图标
-const healthMap = {
- activate: { color: "green", icon: IconCheck },
- error: { color: "red", icon: IconX },
- warning: { color: "yellow", icon: IconAlertTriangle },
- inactive: { color: "gray", icon: IconX },
-};
+import { Card, Text, Stack } from "@mantine/core";
export default function NodeHealthCard({ node }) {
- if (!node || !node.health) return null;
-
- const items = Object.entries(node.health); // [['agent', 'activate'], ...]
+ const health = node.health || {};
return (
-
- 健康状态
+
+ 健康信息
- {items.map(([component, status]) => {
- const { color, icon: Icon } = healthMap[status] || healthMap.inactive;
- return (
-
- {/* 组件名 */}
-
- {component}
-
-
- {/* 状态标签 */}
- } variant="filled">
- {status}
-
-
- );
- })}
+ 日志: {health.log || "无"}
+ 指标: {health.metric || "无"}
);
diff --git a/src/web/src/components/NodeLabelCard.jsx b/src/web/src/components/NodeLabelCard.jsx
index 508f552..2a6fbd0 100644
--- a/src/web/src/components/NodeLabelCard.jsx
+++ b/src/web/src/components/NodeLabelCard.jsx
@@ -1,29 +1,54 @@
-import { useState } from "react";
-import { Card, Text, Group, TextInput, Stack, ActionIcon } from "@mantine/core";
-import { IconEdit, IconX, IconCheck } from "@tabler/icons-react";
+import { useState, useEffect } from "react";
+import { Card, Text, Group, TextInput, Stack, ActionIcon, Badge } from "@mantine/core";
+import { IconEdit, IconX, IconCheck, IconPlus, IconTrash } from "@tabler/icons-react";
import { apiRequest } from "../config/request";
import { EXTERNAL_API } from "../config/api";
-export default function NodeLabelCard({ node, onSaved }) {
+export default function NodeLabelCard({ nodeId, labels = [], onSaved }) {
const [editing, setEditing] = useState(false);
- const [label, setLabel] = useState(node.label || {});
+ const [tagList, setTagList] = useState([]);
+ const [tagColors, setTagColors] = useState([]);
+ const [newTag, setNewTag] = useState("");
const [saving, setSaving] = useState(false);
+ const randomColor = () => {
+ const colors = ["red","pink","grape","violet","indigo","blue","cyan","teal","green","lime","yellow","orange","gray"];
+ return colors[Math.floor(Math.random() * colors.length)];
+ };
+
+ useEffect(() => {
+ const arr = Array.isArray(labels) ? labels : [];
+ setTagList(arr);
+ setTagColors(arr.map(() => randomColor()));
+ }, [labels]);
+
+ const removeTag = (index) => {
+ setTagList((prev) => prev.filter((_, i) => i !== index));
+ setTagColors((prev) => prev.filter((_, i) => i !== index));
+ };
+
+ const updateTag = (index, value) => {
+ setTagList((prev) => prev.map((t, i) => (i === index ? value : t)));
+ };
+
+ const addTag = () => {
+ if (newTag && !tagList.includes(newTag)) {
+ setTagList((prev) => [...prev, newTag]);
+ setTagColors((prev) => [...prev, randomColor()]);
+ setNewTag("");
+ }
+ };
+
const handleSave = async () => {
setSaving(true);
try {
- const res = await apiRequest(
- `${EXTERNAL_API.MASTER_NODES}/${node.id}`,
- {
- method: "PUT",
- body: JSON.stringify({ label }),
- },
- "标签已更新"
- );
- if (res) {
- setEditing(false);
- onSaved && onSaved();
- }
+ await apiRequest(`${EXTERNAL_API.MASTER_NODES}/${nodeId}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ label: tagList }),
+ });
+ setEditing(false);
+ onSaved && onSaved();
} finally {
setSaving(false);
}
@@ -36,31 +61,34 @@ export default function NodeLabelCard({ node, onSaved }) {
{editing ? (
<>
-
-
-
- setEditing(false)}>
-
-
+
+ setEditing(false)}>
>
) : (
- setEditing(true)}>
-
-
+ setEditing(true)}>
)}
{editing ? (
-
- setLabel({ ...label, tag: e.target.value })}
- />
+
+ {tagList.map((tag, idx) => (
+
+ updateTag(idx, e.target.value)} />
+ removeTag(idx)}>
+
+ ))}
+
+ setNewTag(e.target.value)} onKeyDown={(e) => e.key === "Enter" && addTag()} />
+
+
) : (
- 标签: {label.tag}
+
+ {tagList.length > 0 ? tagList.map((tag, idx) => (
+ {tag}
+ )) : 暂无标签}
+
)}
);
diff --git a/src/web/src/components/NodeMetaCard.jsx b/src/web/src/components/NodeMetaCard.jsx
index 025da48..ad69e83 100644
--- a/src/web/src/components/NodeMetaCard.jsx
+++ b/src/web/src/components/NodeMetaCard.jsx
@@ -4,13 +4,17 @@ export default function NodeMetaCard({ node }) {
const meta = node.meta_data || {};
return (
-
+
元数据信息
- 主机: {meta.host}
- IP: {meta.ip}
- 区域: {meta.region}
- 可用区: {meta.zone}
+ 主机名: {meta.hostname}
+ IP: {meta.ip}
+ 环境: {meta.env}
+ 用户: {meta.user}
+ 实例: {meta.instance}
+ CPU 数量: {meta.cpu_number}
+ 内存: {(meta.memory_in_bytes / 1024 / 1024).toFixed(2)} MB
+ GPU 数量: {meta.gpu_number}
);
diff --git a/src/web/src/components/NodeModal.jsx b/src/web/src/components/NodeModal.jsx
deleted file mode 100644
index 98b9e51..0000000
--- a/src/web/src/components/NodeModal.jsx
+++ /dev/null
@@ -1,142 +0,0 @@
-import { useState, useEffect } from "react";
-import { Modal, TextInput, Select, Button } from "@mantine/core";
-import { notifications } from "@mantine/notifications";
-import { EXTERNAL_API } from "../config/api";
-import { statusOptions } from "../config/status";
-import { apiRequest } from "../config/request";
-
-export default function NodeModal({ opened, onClose, node, onSaved }) {
- const [form, setForm] = useState({ name: "", status: "healthy", load: 0 });
- const [loading, setLoading] = useState(false);
- const [errors, setErrors] = useState({ name: "", load: "" });
-
- // 初始化表单
- useEffect(() => {
- setForm(node ? { ...node } : { name: "", status: "healthy", load: 0 });
- setErrors({ name: "", load: "" });
- }, [node]);
-
- // 实时校验
- const validateField = (field, value) => {
- switch (field) {
- case "name":
- setErrors((prev) => ({
- ...prev,
- name: value.trim() ? "" : "名称不能为空",
- }));
- break;
- case "load":
- const loadNum = Number(value);
- setErrors((prev) => ({
- ...prev,
- load:
- isNaN(loadNum) || loadNum < 0 || loadNum > 100
- ? "负载必须是 0~100 的数字"
- : "",
- }));
- break;
- default:
- break;
- }
- };
-
- const handleSave = async () => {
- const name = form.name.trim();
- const load = Number(form.load);
- let hasError = false;
-
- if (!name) {
- setErrors((prev) => ({ ...prev, name: "名称不能为空" }));
- notifications.show({
- title: "校验失败",
- message: "名称不能为空 ❌",
- color: "red",
- });
- hasError = true;
- }
- if (isNaN(load) || load < 0 || load > 100) {
- setErrors((prev) => ({ ...prev, load: "负载必须是 0~100 的数字" }));
- notifications.show({
- title: "校验失败",
- message: "负载必须是 0~100 的数字 ❌",
- color: "red",
- });
- hasError = true;
- }
- if (hasError) return;
-
- const method = node ? "PUT" : "POST";
- const url = node ? `${EXTERNAL_API.MASTER_NODES}/${node.id}` : EXTERNAL_API.MASTER_NODES;
-
- setLoading(true);
- try {
- await apiRequest(url, {
- method,
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ ...form, name, load }),
- });
- notifications.show({
- title: "成功",
- message: node ? "节点已更新 ✅" : "节点已新增 ✅",
- color: "green",
- autoClose: 3000,
- });
-
- setTimeout(() => {
- onClose();
- onSaved();
- }, 3000);
- } catch (err) {
- notifications.show({
- title: "错误",
- message: `操作失败: ${err.message || "未知错误"}`,
- color: "red",
- });
- } finally {
- setLoading(false);
- }
- };
-
- return (
-
- {
- const value = e.target.value;
- setForm((prev) => ({ ...prev, name: value }));
- validateField("name", value);
- }}
- mb="sm"
- error={errors.name}
- required
- />
- setForm((prev) => ({ ...prev, status: val }))}
- mb="sm"
- />
- {
- const value = e.target.value;
- setForm((prev) => ({ ...prev, load: value }));
- validateField("load", value);
- }}
- mb="sm"
- error={errors.load}
- />
-
-
- );
-}
diff --git a/src/web/src/components/NodeStatus.jsx b/src/web/src/components/NodeStatus.jsx
index 7438c20..7b3a52f 100644
--- a/src/web/src/components/NodeStatus.jsx
+++ b/src/web/src/components/NodeStatus.jsx
@@ -1,7 +1,7 @@
import { statusMap } from "../config/status";
export default function NodeStatus({ status }) {
- const { color, labelCn } = statusMap[status] || { color: "gray", labelCn: "未知" };
+ const { color, label } = statusMap[status] || { color: "gray", label: "未知" };
return (
@@ -15,7 +15,7 @@ export default function NodeStatus({ status }) {
marginRight: 8,
}}
/>
- {labelCn}
+ {label}
);
}
diff --git a/src/web/src/components/NodeTable.jsx b/src/web/src/components/NodeTable.jsx
new file mode 100644
index 0000000..8c816a8
--- /dev/null
+++ b/src/web/src/components/NodeTable.jsx
@@ -0,0 +1,156 @@
+import { useState, useEffect } from "react";
+import { Card, Table, Button, Loader, Center, TextInput, Select, Group, Anchor, Text } from "@mantine/core";
+import { Link } from "react-router-dom";
+import NodeStatus from "./NodeStatus";
+import PaginationControl from "./PaginationControl";
+import { apiRequest } from "../config/request";
+import { EXTERNAL_API } from "../config/api";
+import { statusOptions } from "../config/status";
+
+export function NodeTable({
+ withSearch = false,
+ withPagination = false,
+ withActions = false,
+ clusterData = null, // 直接传入数据(Dashboard 用)
+ fetchDetail, // 详情函数(NodePage 用)
+ title,
+ viewMoreLink,
+}) {
+ const [nodes, setNodes] = useState([]);
+ const [totalCount, setTotalCount] = useState(0);
+ const [page, setPage] = useState(1);
+ const [pageSize, setPageSize] = useState(5);
+ const [loading, setLoading] = useState(false);
+
+ // 搜索条件
+ const [searchName, setSearchName] = useState("");
+ const [searchStatus, setSearchStatus] = useState("");
+
+ // 拉取节点数据(仅 NodePage 使用)
+ const fetchNodes = async (params = {}) => {
+ if (!withPagination && !withSearch) return; // Dashboard 只用 clusterData
+ setLoading(true);
+ try {
+ const query = new URLSearchParams({
+ page: params.page || page,
+ pageSize: params.pageSize || pageSize,
+ name: params.name !== undefined ? params.name : searchName,
+ status: params.status !== undefined ? params.status : searchStatus,
+ }).toString();
+
+ const result = await apiRequest(`${EXTERNAL_API.MASTER_NODES}?${query}`);
+ setNodes(result.data);
+ setTotalCount(result.total || 0);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // 初始化加载
+ useEffect(() => {
+ if (withPagination || withSearch) {
+ fetchNodes();
+ } else if (clusterData) {
+ setNodes(clusterData.nodes || []);
+ setTotalCount(clusterData.total_nodes || 0);
+ }
+ }, [clusterData]);
+
+ // 表格行
+ const rows = nodes.map((node) => (
+
+ {node.id}
+ {node.name}
+
+ {node.type}
+ {node.version}
+ {withActions && (
+
+
+
+ )}
+
+ ));
+
+ return (
+
+ {/* 标题 + 查看更多 */}
+
+
+ {(title || viewMoreLink) && (
+
+ {title && {title}}
+ {viewMoreLink && (
+
+ 查看更多
+
+ )}
+
+ )}
+ {/* 搜索区域 */}
+ {withSearch && (
+
+ setSearchName(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && fetchNodes({ page: 1, name: searchName, status: searchStatus })}
+ style={{ width: 200 }}
+ />
+
+
+
+
+ )}
+
+ {loading ? (
+
+ ) : (
+ <>
+
+
+
+ ID
+ 名称
+ 状态
+ 类型
+ 版本
+ {withActions && 操作}
+
+
+ {rows}
+
+
+ {withPagination && (
+ {
+ setPage(p);
+ fetchNodes({ page: p });
+ }}
+ onPageSizeChange={(size) => {
+ setPageSize(size);
+ setPage(1);
+ fetchNodes({ page: 1, pageSize: size });
+ }}
+ />
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/web/src/config/api.js b/src/web/src/config/api.js
index 8b7cf6a..391c7be 100644
--- a/src/web/src/config/api.js
+++ b/src/web/src/config/api.js
@@ -1,17 +1,13 @@
-export const NODES_API_URL = "http://master.argus.com/api/v1/nodes";
-
-export const INTERNAL_API = {
- CLUSTER: "/api/v1/web/cluster",
- HEALTH: "/api/v1/web/health",
- ALERTS: "/api/v1/web/alerts",
-}
-
export const EXTERNAL_API = {
- MASTER_NODES: "http://master.argus.com/api/v1/nodes",
+ MASTER_NODES: "http://master.argus.com/api/v1/master/nodes",
+ MASTER_NODES_STATISTICS: "http://master.argus.com/api/v1/master/nodes/statistics",
+ ALERTS_INFOS: "http://localhost:9093/api/v2/alerts",
}
+// proxy location定位到具体位置。
+// proxy需要单独的机器,nginx配置。提供对外的endpoint,通过算力平台映射。
export const EXTERNAL_HOST = {
- ALERTS: "http://alertmanager.alert.argus.com",
+ ALERTS: "http://localhost:9093",
GRAFANA: "http://grafana.metric.argus.com",
PROMETHEUS: "http://prometheus.metric.argus.com",
ES: "http://es.log.argus.com",
diff --git a/src/web/src/config/request.js b/src/web/src/config/request.js
index db8db51..34ddc83 100644
--- a/src/web/src/config/request.js
+++ b/src/web/src/config/request.js
@@ -34,59 +34,73 @@ export async function apiRequest(url, options = {}, successMsg) {
return data;
} catch (err) {
- notifications.show({
- title: "操作失败",
- message: err.message || "接口调用失败",
- color: "red",
- });
+ console.log("API 请求错误:", err);
+ // notifications.show({
+ // title: "操作失败",
+ // message: err.message || "接口调用失败",
+ // color: "red",
+ // });
// throw err; // 继续抛出错误,方便上层处理
}
// 返回 mock 数据
- if (url.includes("/api/v1/nodes")) {
- if (/\/api\/v1\/nodes\/[^\/]+$/.test(url)) {
+ if (url.includes("/api/v1/master/nodes")) {
+ if (url.includes("/statistics")) {
return {
- "id": "node1",
- "name": "Node 1",
+ "total": 30,
+ "status_statistics": [
+ { "status": "online", "count": 20 },
+ { "status": "offline", "count": 10 },
+ ]
+ };
+ }
+ if (/\/api\/v1\/master\/nodes\/[^\/]+$/.test(url)) {
+ return {
+ "id": "A1", // master分配的唯一ID, A代表Agent,数字1开始按顺序编号c
+ "name": "Node 1", // agent上报时提供的hostname
"status": "online",
"config": {
- // !!! 格式待定, 下发给agent用的
- "max_connections": 100,
- "timeout": 30,
- "log_level": "info"
+ // !!! 预留字段,KV形式非固定key, web配置下发给agent用的
+ "setting1": "value1",
+ "setting2": "value2",
+ "setting3": "value3",
+ "setting4": "value4"
},
"meta_data": {
- // 元数据: host, ip,
- "host": "192.168.1.1",
- "ip": "192.168.1.1",
- "region": "shanghai",
- "zone": "shanghai-1",
+ // 元数据: 容器生命周期内不变
+ "hostname": "dev-yyrshare-nbnyx10-cp2f-pod-0",
+ "ip": "177.177.74.223",
+ "env": "dev", // 环境名, 从hostname中提取第一段
+ "user": "yyrshare", // 账户名,从hostname中提取第二段
+ "instance": "nbnyx10", // 容器示例名,从hostname中提取第三段
+ "cpu_number": 16,
+ "memory_in_bytes": 2015040000,
+ "gpu_number": 8
},
- "label": {
+ "label": [
// 用户或运维人员绑定到节点上的标签,业务属性, tag
- "tag": "value1",
+ "gpu", "exp001"
+ ],
+ "health": { // agent收集到各端上模块的health描述文件
+ "log": "", //字符串,转义,防止换行、引号
+ "metric": ""
},
- "health": {
- "agent": "activate",
- "log": "error",
- "metric": "activate",
- },
- "last_updated": "2023-10-05T12:00:00Z",
- "type": "worker"
+ "register_time": "2023-10-03T12:00:00Z", // 节点注册时间
+ "last_report": "2023-10-03T12:00:00Z", // 最近一次上报时间
+ "last_updated": "2023-10-05T12:00:00Z", // 更新NodeObject落库时间戳
+ "type": "agent" // 缺省为agent,未来可能有新的节点类型
}
+
}
return {
"total": 30,
"data": [
- { id: "node1", name: "节点A", status: "healthy", type: "agent", version: "1.0.0" },
- { id: "node2", name: "节点B", status: "warning", type: "agent", version: "1.0.0"},
- { id: "node3", name: "节点C", status: "error", type: "agent", version: "1.0.0"},
- { id: "node4", name: "节点D", status: "healthy", type: "agent", version: "1.0.0"},
- { id: "node5", name: "节点E", status: "warning", type: "agent", version: "1.0.0"},
- { id: "node6", name: "节点F", status: "error", type: "agent", version: "1.0.0"},
- { id: "node7", name: "节点G", status: "healthy", type: "agent", version: "1.0.0"},
- { id: "node8", name: "节点H", status: "warning", type: "agent", version: "1.0.0"},
+ { id: "node1", name: "节点A", status: "online", type: "agent", version: "1.0.0" },
+ { id: "node2", name: "节点B", status: "online", type: "agent", version: "1.0.0" },
+ { id: "node3", name: "节点C", status: "offline", type: "agent", version: "1.0.0" },
+ { id: "node4", name: "节点D", status: "online", type: "agent", version: "1.0.0" },
+ { id: "node5", name: "节点E", status: "online", type: "agent", version: "1.0.0" },
]
};
}
diff --git a/src/web/src/config/status.js b/src/web/src/config/status.js
index ccf4964..d6a1a48 100644
--- a/src/web/src/config/status.js
+++ b/src/web/src/config/status.js
@@ -7,9 +7,8 @@ import {
} from "@tabler/icons-react";
export const statusMap = {
- healthy: { label: "Healthy", color: "green", labelCn: "健康" },
- warning: { label: "Warning", color: "orange", labelCn: "警告" },
- error: { label: "Error", color: "red", labelCn: "错误" },
+ online: { label: "Online", color: "green"},
+ offline: { label: "Offline", color: "red"},
};
export const statusOptions = Object.entries(statusMap).map(([value, { label }]) => ({
diff --git a/src/web/src/config/utils.js b/src/web/src/config/utils.js
new file mode 100644
index 0000000..f5a8f05
--- /dev/null
+++ b/src/web/src/config/utils.js
@@ -0,0 +1,15 @@
+export function formatRelativeTime(dateStr) {
+ if (!dateStr) return "-";
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diffMs = now - date;
+ const diffSec = Math.floor(diffMs / 1000);
+ const diffMin = Math.floor(diffSec / 60);
+ const diffHour = Math.floor(diffMin / 60);
+ const diffDay = Math.floor(diffHour / 24);
+
+ if (diffSec < 60) return `${diffSec} 秒前`;
+ if (diffMin < 60) return `${diffMin} 分钟前`;
+ if (diffHour < 24) return `${diffHour} 小时前`;
+ return `${diffDay} 天前`;
+}
diff --git a/src/web/src/pages/Alerts.jsx b/src/web/src/pages/Alerts.jsx
index 898bc08..641cd21 100644
--- a/src/web/src/pages/Alerts.jsx
+++ b/src/web/src/pages/Alerts.jsx
@@ -1,18 +1,160 @@
-import { Grid, Stack, Title } from "@mantine/core";
-import EntryCard from "../components/EntryCard";
-import { alertsEntries } from "../config/entries";
+import { useEffect, useState, useMemo } from "react";
+import { Stack, Title, Loader, Center, Group, Button, Badge, ActionIcon } from "@mantine/core";
+import { IconRefresh } from "@tabler/icons-react";
+import { apiRequest } from "../config/request";
+import { EXTERNAL_API } from "../config/api";
+import { AlertStats } from "../components/AlertStats";
+import { AlertFilters } from "../components/AlertFilters";
+import { AlertTable } from "../components/AlertTable";
+import { formatRelativeTime } from "../config/utils";
+import { EXTERNAL_HOST } from "../config/api";
export default function Alerts() {
+ const [alerts, setAlerts] = useState([]);
+ const [stats, setStats] = useState({ critical: 0, warning: 0, info: 0 });
+ const [loading, setLoading] = useState(true);
+ const [filters, setFilters] = useState({ severity: "all", state: "all", instance: "all" });
+ const [page, setPage] = useState(1);
+ const pageSize = 10;
+ const [sortConfig, setSortConfig] = useState({ key: "startsAt", direction: "desc" });
+
+ async function fetchAlerts() {
+ setLoading(true);
+ const data = await apiRequest(EXTERNAL_API.ALERTS_INFOS);
+ if (data && Array.isArray(data)) {
+ setAlerts(data);
+ const counts = { critical: 0, warning: 0, info: 0 };
+ data.forEach((alert) => {
+ const sev = alert.labels?.severity || "info";
+ if (sev === "critical") counts.critical++;
+ else if (sev === "warning") counts.warning++;
+ else counts.info++;
+ });
+ setStats(counts);
+ }
+ setLoading(false);
+ }
+
+ useEffect(() => {
+ fetchAlerts();
+ const timer = setInterval(fetchAlerts, 30000);
+ return () => clearInterval(timer);
+ }, []);
+
+ // 节点选项
+ const nodeOptions = useMemo(() => {
+ const nodes = Array.from(new Set(alerts.map((a) => a.labels?.instance).filter(Boolean)));
+ return [{ value: "all", label: "全部" }, ...nodes.map((n) => ({ value: n, label: n }))];
+ }, [alerts]);
+
+ // 过滤 & 排序 & 分页逻辑
+ const filteredAlerts = useMemo(() => {
+ return alerts.filter((alert) => {
+ const sev = alert.labels?.severity || "info";
+ const state = alert.status?.state || "active";
+ const instance = alert.labels?.instance || "";
+ return (
+ (filters.severity === "all" || filters.severity === sev) &&
+ (filters.state === "all" || filters.state === state) &&
+ (filters.instance === "all" || filters.instance === instance)
+ );
+ });
+ }, [alerts, filters]);
+
+ const sortedAlerts = useMemo(() => {
+ const sorted = [...filteredAlerts];
+ if (sortConfig.key) {
+ sorted.sort((a, b) => {
+ let valA, valB;
+ if (sortConfig.key === "severity") {
+ const map = { critical: 3, warning: 2, info: 1 };
+ valA = map[a.labels?.severity] || 0;
+ valB = map[b.labels?.severity] || 0;
+ } else if (["startsAt", "endsAt", "updatedAt"].includes(sortConfig.key)) {
+ valA = new Date(a[sortConfig.key]).getTime() || 0;
+ valB = new Date(b[sortConfig.key]).getTime() || 0;
+ } else if (sortConfig.key === "instance") {
+ valA = a.labels?.instance || "";
+ valB = b.labels?.instance || "";
+ } else {
+ valA = a.labels?.alertname || "";
+ valB = b.labels?.alertname || "";
+ }
+ if (valA < valB) return sortConfig.direction === "asc" ? -1 : 1;
+ if (valA > valB) return sortConfig.direction === "asc" ? 1 : -1;
+ return 0;
+ });
+ }
+ return sorted;
+ }, [filteredAlerts, sortConfig]);
+
+ const paginatedAlerts = useMemo(() => {
+ const start = (page - 1) * pageSize;
+ return sortedAlerts.slice(start, start + pageSize);
+ }, [sortedAlerts, page]);
+
+ // 颜色 & Badge
+ const getRowColor = (alert) => {
+ if (alert.status?.state === "resolved") return "gray.1";
+ const sev = alert.labels?.severity;
+ if (sev === "critical") return "red.0";
+ if (sev === "warning") return "orange.0";
+ if (sev === "info") return "blue.0";
+ return undefined;
+ };
+ const getSeverityColor = (sev) => {
+ if (sev === "critical") return "red";
+ if (sev === "warning") return "orange";
+ if (sev === "info") return "blue";
+ return "gray";
+ };
+ const getStateBadge = (state) => (
+
+ {state}
+
+ );
+ const handleSort = (key) => {
+ setSortConfig((prev) => ({
+ key,
+ direction: prev.key === key && prev.direction === "asc" ? "desc" : "asc",
+ }));
+ };
+
return (
- 告警详情
-
- {alertsEntries.map((entry) => (
-
-
-
- ))}
-
+
+ 告警详情
+
+
+
+
+
+
+
+
+
+ {loading ? (
+
+
+
+ ) : (
+
+ )}
);
}
diff --git a/src/web/src/pages/Dashboard.jsx b/src/web/src/pages/Dashboard.jsx
index 561cd94..3bb9cf5 100644
--- a/src/web/src/pages/Dashboard.jsx
+++ b/src/web/src/pages/Dashboard.jsx
@@ -1,10 +1,10 @@
import { useEffect, useState } from "react";
import { Grid, Text } from "@mantine/core";
-import { ClusterTable } from "../components/DashboardNodeTable";
+import { NodeTable } from "../components/NodeTable";
import { HealthCard } from "../components/HealthCard";
-import { AlertCard } from "../components/AlertCard";
+import { AlertStats } from "../components/AlertStats";
import { apiRequest } from "../config/request";
-import { INTERNAL_API, EXTERNAL_API, EXTERNAL_HOST } from "../config/api";
+import { EXTERNAL_API } from "../config/api";
export default function Dashboard() {
const [cluster, setCluster] = useState(null);
@@ -12,14 +12,25 @@ export default function Dashboard() {
const [alerts, setAlerts] = useState(null);
const [loading, setLoading] = useState(true);
+ const countAlerts = (data) => {
+ const stats = { critical: 0, warning: 0, info: 0 };
+ data?.forEach((alert) => {
+ const severity = alert.labels?.severity || "info";
+ if (severity === "critical") stats.critical++;
+ else if (severity === "warning") stats.warning++;
+ else stats.info++;
+ });
+ return stats;
+ };
+
useEffect(() => {
async function fetchData() {
setLoading(true);
try {
const [clusterRes, healthRes, alertsRes] = await Promise.all([
apiRequest(EXTERNAL_API.MASTER_NODES),
- apiRequest(INTERNAL_API.HEALTH),
- apiRequest(INTERNAL_API.ALERTS),
+ apiRequest(EXTERNAL_API.MASTER_NODES_STATISTICS),
+ apiRequest(EXTERNAL_API.ALERTS_INFOS),
]);
setCluster({
@@ -28,17 +39,11 @@ export default function Dashboard() {
});
setHealth({
- healthy_nodes: healthRes?.healthy_nodes || 0,
- warning_nodes: healthRes?.warning_nodes || 0,
- error_nodes: healthRes?.error_nodes || 0,
+ total: healthRes?.total || 0,
+ status_statistics: healthRes?.status_statistics || [],
});
- setAlerts({
- total_alerts: alertsRes?.total_alerts || 0,
- critical_alerts: alertsRes?.critical_alerts || 0,
- major_alerts: alertsRes?.major_alerts || 0,
- minor_alerts: alertsRes?.minor_alerts || 0,
- });
+ setAlerts(countAlerts(alertsRes?.data || []));
} catch (err) {
console.error("获取 Dashboard 数据失败:", err);
} finally {
@@ -59,9 +64,12 @@ export default function Dashboard() {
return (
-
+
-
+
+
+
+
);
}
diff --git a/src/web/src/pages/NodePage.jsx b/src/web/src/pages/NodePage.jsx
index 7ac8d00..120966f 100644
--- a/src/web/src/pages/NodePage.jsx
+++ b/src/web/src/pages/NodePage.jsx
@@ -1,58 +1,14 @@
-import { useState, useEffect } from "react";
-import {
- Table,
- Button,
- TextInput,
- Select,
- Loader,
- Center,
- Text,
- Grid,
-} from "@mantine/core";
-import { EXTERNAL_API } from "../config/api";
-import PaginationControl from "../components/PaginationControl";
-import NodeStatus from "../components/NodeStatus";
-import NodeModal from "../components/NodeModal";
-import NodeDetailDrawer from "../components/NodeDetailDrawer";
+import { useState } from "react";
+import { Grid } from "@mantine/core";
import { apiRequest } from "../config/request";
+import { EXTERNAL_API } from "../config/api";
+import { NodeTable } from "../components/NodeTable";
+import NodeDetailDrawer from "../components/NodeDetailDrawer";
export default function NodePage() {
- const [nodes, setNodes] = useState([]);
- const [totalCount, setTotalCount] = useState(0);
- const [page, setPage] = useState(1);
- const [pageSize, setPageSize] = useState(5);
- const [modalOpen, setModalOpen] = useState(false);
- const [editNode, setEditNode] = useState(null);
- const [loading, setLoading] = useState(false);
-
- // 查询条件
- const [searchName, setSearchName] = useState("");
- const [searchStatus, setSearchStatus] = useState("");
-
- // 详情相关
const [selectedNode, setSelectedNode] = useState(null);
- const [detailLoading, setDetailLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
-
- // 获取节点数据(支持查询和分页)
- const fetchNodes = async (params = {}) => {
- setLoading(true);
- try {
- // 构建查询参数
- const query = new URLSearchParams({
- page: params.page || page,
- pageSize: params.pageSize || pageSize,
- name: params.name !== undefined ? params.name : searchName,
- status: params.status !== undefined ? params.status : searchStatus,
- }).toString();
-
- const result = await apiRequest(`${EXTERNAL_API.MASTER_NODES}?${query}`);
- setNodes(result.data);
- setTotalCount(result.total || 0);
- } finally {
- setLoading(false);
- }
- };
+ const [detailLoading, setDetailLoading] = useState(false);
// 获取节点详情
const fetchNodeDetail = async (id) => {
@@ -65,124 +21,17 @@ export default function NodePage() {
setDetailLoading(false);
}
};
- useEffect(() => {
- fetchNodes();
- }, []);
-
- // 搜索
- const handleSearch = () => {
- setPage(1); // 搜索时分页重置
- fetchNodes({ page: 1, name: searchName, status: searchStatus });
- };
return (
- {/* 左侧:表格 */}
-
- {/* 顶部操作区 */}
-
- {/* 搜索区 */}
-
- setSearchName(e.target.value)}
- onKeyDown={(e) => {
- if (e.key === "Enter") handleSearch();
- }}
- style={{ width: 200 }}
- />
-
-
-
-
- {/* 操作按钮 */}
-
-
-
-
-
- {loading ? (
-
-
-
- ) : (
- <>
-
-
-
- ID
- 名称
- 状态
- 类型
- 版本
- 操作
-
-
-
- {nodes.map((node) => (
-
- {node.id}
- {node.name}
-
-
-
- {node.type}
- {node.version}
-
-
-
-
- ))}
-
-
-
-
- {
- setPage(p);
- fetchNodes({ page: p });
- }}
- onPageSizeChange={(size) => {
- setPageSize(size);
- setPage(1);
- fetchNodes({ page: 1, pageSize: size });
- }}
- />
- >
- )}
+ {/* 左侧:节点表格 */}
+
+
{/* 节点详情 Drawer */}
@@ -190,6 +39,7 @@ export default function NodePage() {
opened={drawerOpen}
onClose={() => setDrawerOpen(false)}
nodeId={selectedNode}
+ loading={detailLoading}
/>
);