snapshot: AlertCenter拆分前快照
This commit is contained in:
1 parent
6a40956afa
commit
805cb767a8
23 files changed
+1526
-370
No files matched your search
+8
-3
@@ -10,14 +10,14 @@ LDFLAGS := -s -w -X main.version=$(VERSION) -X main.buildTime=$(BUILD_TIME)
|
|||||||
# 静态编译:关闭 CGO 并使用静态链接
|
# 静态编译:关闭 CGO 并使用静态链接
|
||||||
LINUX_LDFLAGS := -extldflags "-static"
|
LINUX_LDFLAGS := -extldflags "-static"
|
||||||
|
|
||||||
.PHONY: build cross build-linux-amd64 build-linux-arm64 tidy run fmt vet clean help
|
.PHONY: build cross build-linux-amd64 build-linux-arm64 build-darwin-arm64 tidy run fmt vet clean help
|
||||||
|
|
||||||
## build: 本地构建当前平台二进制
|
## build: 本地构建当前平台二进制
|
||||||
build:
|
build:
|
||||||
go build -ldflags "$(LDFLAGS)" -o $(APP) .
|
go build -ldflags "$(LDFLAGS)" -o $(APP) .
|
||||||
|
|
||||||
## cross: 交叉编译 Linux amd64 与 arm64(静态链接,适合容器/嵌入式部署)
|
## cross: 交叉编译 Linux amd64/arm64 + macOS arm64
|
||||||
cross: build-linux-amd64 build-linux-arm64
|
cross: build-linux-amd64 build-linux-arm64 build-darwin-arm64
|
||||||
|
|
||||||
## build-linux-amd64: 交叉编译 linux/amd64
|
## build-linux-amd64: 交叉编译 linux/amd64
|
||||||
build-linux-amd64:
|
build-linux-amd64:
|
||||||
@@ -29,6 +29,11 @@ build-linux-arm64:
|
|||||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
|
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 \
|
||||||
go build -ldflags "$(LDFLAGS) $(LINUX_LDFLAGS)" -o bin/$(APP)-linux-arm64 .
|
go build -ldflags "$(LDFLAGS) $(LINUX_LDFLAGS)" -o bin/$(APP)-linux-arm64 .
|
||||||
|
|
||||||
|
## build-darwin-arm64: 编译 macOS/arm64 (Apple Silicon) // Logan.Li
|
||||||
|
build-darwin-arm64:
|
||||||
|
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \
|
||||||
|
go build -ldflags "$(LDFLAGS)" -o bin/$(APP)-darwin-arm64 .
|
||||||
|
|
||||||
## tidy: 整理依赖
|
## tidy: 整理依赖
|
||||||
tidy:
|
tidy:
|
||||||
go mod tidy
|
go mod tidy
|
||||||
|
|||||||
+113
-21
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# AIOps Agent 一键安装脚本 — 支持 RHEL/CentOS/Rocky/Alma/Amazon/Aliyun/Debian/Ubuntu 全系列
|
# AIOps Agent 一键安装脚本 — 支持 Linux(systemd) 和 macOS(launchd)
|
||||||
# 用法: curl -fsSL http://YOUR_SERVER/agent/install.sh | bash -s -- --server http://YOUR_SERVER:8080 --token TOKEN
|
# 用法: curl -fsSL http://YOUR_SERVER/agent/install.sh | sudo bash -s -- --server http://YOUR_SERVER:8080 --token TOKEN
|
||||||
# 署名 Logan.Li
|
# 署名 Logan.Li
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -10,8 +10,24 @@ AIOPS_SERVER=""
|
|||||||
AGENT_TOKEN=""
|
AGENT_TOKEN=""
|
||||||
INSTALL_DIR="/usr/local/bin"
|
INSTALL_DIR="/usr/local/bin"
|
||||||
SERVICE_NAME="aiops-agent"
|
SERVICE_NAME="aiops-agent"
|
||||||
|
PLIST_LABEL="com.ezops.aiops-agent"
|
||||||
ARCH=$(uname -m)
|
ARCH=$(uname -m)
|
||||||
OS=$(cat /etc/os-release 2>/dev/null | grep '^ID=' | cut -d'"' -f2 || echo "unknown")
|
KERNEL=$(uname -s) # Linux 或 Darwin
|
||||||
|
|
||||||
|
# 检测 OS 显示名
|
||||||
|
detect_os() {
|
||||||
|
if [ "$KERNEL" = "Darwin" ]; then
|
||||||
|
echo "$(sw_vers -productName 2>/dev/null || echo 'macOS') $(sw_vers -productVersion 2>/dev/null || echo '')"
|
||||||
|
elif [ -f /etc/os-release ]; then
|
||||||
|
. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-${NAME:-Linux}}" || echo "Linux"
|
||||||
|
else
|
||||||
|
echo "Linux"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
OS_DISPLAY=$(detect_os)
|
||||||
|
|
||||||
|
# OS 小写 (linux / darwin)
|
||||||
|
OS_LOWER=$(echo "$KERNEL" | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
# 解析命令行参数
|
# 解析命令行参数
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
@@ -30,14 +46,17 @@ echo " ╔═══════════════════════
|
|||||||
echo " ║ AIOps Agent 一键安装 ║"
|
echo " ║ AIOps Agent 一键安装 ║"
|
||||||
echo " ╚══════════════════════════════════════════╝"
|
echo " ╚══════════════════════════════════════════╝"
|
||||||
echo ""
|
echo ""
|
||||||
echo " 系统: ${OS} (${ARCH})"
|
echo " 系统: ${OS_DISPLAY} (${ARCH})"
|
||||||
|
echo " 内核: ${KERNEL}"
|
||||||
echo " 版本: ${AGENT_VERSION}"
|
echo " 版本: ${AGENT_VERSION}"
|
||||||
echo " 后端: ${AIOPS_SERVER:-未指定}"
|
echo " 后端: ${AIOPS_SERVER:-未指定}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 检查 root 权限
|
# 检查 root 权限
|
||||||
if [ "$EUID" -ne 0 ]; then
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
echo "❌ 请使用 root 用户或 sudo 执行此脚本"
|
echo "❌ 请使用 root 或 sudo 执行此脚本"
|
||||||
|
echo " 推荐用法: curl -fsSL <url> | sudo bash -s -- --server <url> --token <token>"
|
||||||
|
echo " (sudo 放在 bash 前,不是 curl 前)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -50,10 +69,10 @@ case "$ARCH" in
|
|||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
echo " 匹配: linux/${DOWNLOAD_ARCH}"
|
echo " 匹配: ${OS_LOWER}/${DOWNLOAD_ARCH}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 安装依赖工具(curl 在大多数发行版自带,这里确保有)
|
# 安装依赖工具
|
||||||
if command -v curl &> /dev/null; then
|
if command -v curl &> /dev/null; then
|
||||||
echo "✓ curl 已安装"
|
echo "✓ curl 已安装"
|
||||||
else
|
else
|
||||||
@@ -64,6 +83,8 @@ else
|
|||||||
dnf install -y curl
|
dnf install -y curl
|
||||||
elif command -v apt-get &> /dev/null; then
|
elif command -v apt-get &> /dev/null; then
|
||||||
apt-get update -qq && apt-get install -y curl
|
apt-get update -qq && apt-get install -y curl
|
||||||
|
elif command -v brew &> /dev/null; then
|
||||||
|
brew install curl
|
||||||
else
|
else
|
||||||
echo "❌ 无法确定包管理器,请手动安装 curl"
|
echo "❌ 无法确定包管理器,请手动安装 curl"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -76,11 +97,15 @@ if [ -z "$AIOPS_SERVER" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
BINARY_URL="${AIOPS_SERVER}/agent/aiops-agent-linux-${DOWNLOAD_ARCH}"
|
BINARY_URL="${AIOPS_SERVER}/agent/aiops-agent-${OS_LOWER}-${DOWNLOAD_ARCH}"
|
||||||
echo "→ 下载 Agent: ${BINARY_URL}"
|
echo "→ 下载 Agent: ${BINARY_URL}"
|
||||||
|
|
||||||
curl -fSL -o /tmp/aiops-agent "${BINARY_URL}" || {
|
curl -fSL -o /tmp/aiops-agent "${BINARY_URL}" || {
|
||||||
echo "❌ 下载失败,请检查后端地址和网络"
|
echo "❌ 下载失败: ${BINARY_URL}"
|
||||||
|
echo " 请检查: 1) 后端地址是否正确 2) 该平台是否有对应二进制"
|
||||||
|
if [ "$OS_LOWER" = "darwin" ]; then
|
||||||
|
echo " 提示: macOS 二进制需要后端编译 darwin 版本 (make build-darwin-arm64)"
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
chmod +x /tmp/aiops-agent
|
chmod +x /tmp/aiops-agent
|
||||||
@@ -88,13 +113,79 @@ mv /tmp/aiops-agent "${INSTALL_DIR}/aiops-agent"
|
|||||||
|
|
||||||
echo "✓ 安装到 ${INSTALL_DIR}/aiops-agent"
|
echo "✓ 安装到 ${INSTALL_DIR}/aiops-agent"
|
||||||
|
|
||||||
# 配置 systemd 服务
|
|
||||||
if [ -z "$AGENT_TOKEN" ]; then
|
if [ -z "$AGENT_TOKEN" ]; then
|
||||||
echo "⚠ 未提供 --token,使用空令牌"
|
echo "⚠ 未提供 --token,使用空令牌"
|
||||||
AGENT_TOKEN="none"
|
AGENT_TOKEN="none"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF
|
# ─── 平台分支: Linux(systemd) / macOS(launchd) ───
|
||||||
|
|
||||||
|
if [ "$OS_LOWER" = "darwin" ]; then
|
||||||
|
# ===== macOS: launchd 服务 =====
|
||||||
|
PLIST_PATH="/Library/LaunchDaemons/${PLIST_LABEL}.plist"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$PLIST_PATH")"
|
||||||
|
|
||||||
|
cat > "$PLIST_PATH" << EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>${PLIST_LABEL}</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>${INSTALL_DIR}/aiops-agent</string>
|
||||||
|
<string>-server</string>
|
||||||
|
<string>${AIOPS_SERVER}</string>
|
||||||
|
<string>-interval</string>
|
||||||
|
<string>10</string>
|
||||||
|
<string>-token</string>
|
||||||
|
<string>${AGENT_TOKEN}</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/var/log/aiops-agent.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/var/log/aiops-agent.err.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✓ 创建 launchd 服务 ($PLIST_PATH)"
|
||||||
|
|
||||||
|
# 停止旧实例(如果存在)
|
||||||
|
launchctl unload "$PLIST_PATH" 2>/dev/null || true
|
||||||
|
# 加载并启动
|
||||||
|
launchctl load -w "$PLIST_PATH"
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# 检查运行状态
|
||||||
|
if launchctl list | grep -q "$PLIST_LABEL"; then
|
||||||
|
echo ""
|
||||||
|
echo "✅ 安装成功!Agent 已启动并设置为开机自启"
|
||||||
|
echo ""
|
||||||
|
echo " 查看状态: launchctl list | grep aiops"
|
||||||
|
echo " 查看日志: tail -f /var/log/aiops-agent.log"
|
||||||
|
echo " 手动停止: launchctl unload $PLIST_PATH"
|
||||||
|
echo " 手动启动: launchctl load -w $PLIST_PATH"
|
||||||
|
echo " 卸载: launchctl unload $PLIST_PATH && rm -f ${INSTALL_DIR}/aiops-agent $PLIST_PATH"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo "❌ Agent 启动失败,请检查日志:"
|
||||||
|
echo " cat /var/log/aiops-agent.err.log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
# ===== Linux: systemd 服务 =====
|
||||||
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
|
||||||
|
cat > "$SERVICE_FILE" << EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=AIOps Monitoring Agent
|
Description=AIOps Monitoring Agent
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
@@ -111,25 +202,26 @@ LimitNOFILE=65536
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "✓ 创建 systemd 服务"
|
echo "✓ 创建 systemd 服务"
|
||||||
|
|
||||||
# 启动服务
|
# 启动服务
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable ${SERVICE_NAME}
|
systemctl enable ${SERVICE_NAME}
|
||||||
systemctl restart ${SERVICE_NAME}
|
systemctl restart ${SERVICE_NAME}
|
||||||
|
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
if systemctl is-active --quiet ${SERVICE_NAME}; then
|
if systemctl is-active --quiet ${SERVICE_NAME}; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ 安装成功!Agent 已启动并设置为开机自启"
|
echo "✅ 安装成功!Agent 已启动并设置为开机自启"
|
||||||
echo ""
|
echo ""
|
||||||
echo " 查看状态: systemctl status ${SERVICE_NAME}"
|
echo " 查看状态: systemctl status ${SERVICE_NAME}"
|
||||||
echo " 查看日志: journalctl -u ${SERVICE_NAME} -f"
|
echo " 查看日志: journalctl -u ${SERVICE_NAME} -f"
|
||||||
echo " 卸载: ${0} --uninstall || systemctl stop ${SERVICE_NAME} && rm -f ${INSTALL_DIR}/aiops-agent /etc/systemd/system/${SERVICE_NAME}.service && systemctl daemon-reload"
|
echo " 卸载: systemctl stop ${SERVICE_NAME} && rm -f ${INSTALL_DIR}/aiops-agent ${SERVICE_FILE} && systemctl daemon-reload"
|
||||||
echo ""
|
echo ""
|
||||||
else
|
else
|
||||||
echo "❌ Agent 启动失败,请检查日志:"
|
echo "❌ Agent 启动失败,请检查日志:"
|
||||||
echo " journalctl -u ${SERVICE_NAME} -n 20"
|
echo " journalctl -u ${SERVICE_NAME} -n 20"
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
Executable
BIN
Binary file not shown.
+113
-21
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# AIOps Agent 一键安装脚本 — 支持 RHEL/CentOS/Rocky/Alma/Amazon/Aliyun/Debian/Ubuntu 全系列
|
# AIOps Agent 一键安装脚本 — 支持 Linux(systemd) 和 macOS(launchd)
|
||||||
# 用法: curl -fsSL http://YOUR_SERVER/agent/install.sh | bash -s -- --server http://YOUR_SERVER:8080 --token TOKEN
|
# 用法: curl -fsSL http://YOUR_SERVER/agent/install.sh | sudo bash -s -- --server http://YOUR_SERVER:8080 --token TOKEN
|
||||||
# 署名 Logan.Li
|
# 署名 Logan.Li
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
@@ -10,8 +10,24 @@ AIOPS_SERVER=""
|
|||||||
AGENT_TOKEN=""
|
AGENT_TOKEN=""
|
||||||
INSTALL_DIR="/usr/local/bin"
|
INSTALL_DIR="/usr/local/bin"
|
||||||
SERVICE_NAME="aiops-agent"
|
SERVICE_NAME="aiops-agent"
|
||||||
|
PLIST_LABEL="com.ezops.aiops-agent"
|
||||||
ARCH=$(uname -m)
|
ARCH=$(uname -m)
|
||||||
OS=$(cat /etc/os-release 2>/dev/null | grep '^ID=' | cut -d'"' -f2 || echo "unknown")
|
KERNEL=$(uname -s) # Linux 或 Darwin
|
||||||
|
|
||||||
|
# 检测 OS 显示名
|
||||||
|
detect_os() {
|
||||||
|
if [ "$KERNEL" = "Darwin" ]; then
|
||||||
|
echo "$(sw_vers -productName 2>/dev/null || echo 'macOS') $(sw_vers -productVersion 2>/dev/null || echo '')"
|
||||||
|
elif [ -f /etc/os-release ]; then
|
||||||
|
. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-${NAME:-Linux}}" || echo "Linux"
|
||||||
|
else
|
||||||
|
echo "Linux"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
OS_DISPLAY=$(detect_os)
|
||||||
|
|
||||||
|
# OS 小写 (linux / darwin)
|
||||||
|
OS_LOWER=$(echo "$KERNEL" | tr '[:upper:]' '[:lower:]')
|
||||||
|
|
||||||
# 解析命令行参数
|
# 解析命令行参数
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
@@ -30,14 +46,17 @@ echo " ╔═══════════════════════
|
|||||||
echo " ║ AIOps Agent 一键安装 ║"
|
echo " ║ AIOps Agent 一键安装 ║"
|
||||||
echo " ╚══════════════════════════════════════════╝"
|
echo " ╚══════════════════════════════════════════╝"
|
||||||
echo ""
|
echo ""
|
||||||
echo " 系统: ${OS} (${ARCH})"
|
echo " 系统: ${OS_DISPLAY} (${ARCH})"
|
||||||
|
echo " 内核: ${KERNEL}"
|
||||||
echo " 版本: ${AGENT_VERSION}"
|
echo " 版本: ${AGENT_VERSION}"
|
||||||
echo " 后端: ${AIOPS_SERVER:-未指定}"
|
echo " 后端: ${AIOPS_SERVER:-未指定}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 检查 root 权限
|
# 检查 root 权限
|
||||||
if [ "$EUID" -ne 0 ]; then
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
echo "❌ 请使用 root 用户或 sudo 执行此脚本"
|
echo "❌ 请使用 root 或 sudo 执行此脚本"
|
||||||
|
echo " 推荐用法: curl -fsSL <url> | sudo bash -s -- --server <url> --token <token>"
|
||||||
|
echo " (sudo 放在 bash 前,不是 curl 前)"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -50,10 +69,10 @@ case "$ARCH" in
|
|||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
echo " 匹配: linux/${DOWNLOAD_ARCH}"
|
echo " 匹配: ${OS_LOWER}/${DOWNLOAD_ARCH}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 安装依赖工具(curl 在大多数发行版自带,这里确保有)
|
# 安装依赖工具
|
||||||
if command -v curl &> /dev/null; then
|
if command -v curl &> /dev/null; then
|
||||||
echo "✓ curl 已安装"
|
echo "✓ curl 已安装"
|
||||||
else
|
else
|
||||||
@@ -64,6 +83,8 @@ else
|
|||||||
dnf install -y curl
|
dnf install -y curl
|
||||||
elif command -v apt-get &> /dev/null; then
|
elif command -v apt-get &> /dev/null; then
|
||||||
apt-get update -qq && apt-get install -y curl
|
apt-get update -qq && apt-get install -y curl
|
||||||
|
elif command -v brew &> /dev/null; then
|
||||||
|
brew install curl
|
||||||
else
|
else
|
||||||
echo "❌ 无法确定包管理器,请手动安装 curl"
|
echo "❌ 无法确定包管理器,请手动安装 curl"
|
||||||
exit 1
|
exit 1
|
||||||
@@ -76,11 +97,15 @@ if [ -z "$AIOPS_SERVER" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
BINARY_URL="${AIOPS_SERVER}/agent/aiops-agent-linux-${DOWNLOAD_ARCH}"
|
BINARY_URL="${AIOPS_SERVER}/agent/aiops-agent-${OS_LOWER}-${DOWNLOAD_ARCH}"
|
||||||
echo "→ 下载 Agent: ${BINARY_URL}"
|
echo "→ 下载 Agent: ${BINARY_URL}"
|
||||||
|
|
||||||
curl -fSL -o /tmp/aiops-agent "${BINARY_URL}" || {
|
curl -fSL -o /tmp/aiops-agent "${BINARY_URL}" || {
|
||||||
echo "❌ 下载失败,请检查后端地址和网络"
|
echo "❌ 下载失败: ${BINARY_URL}"
|
||||||
|
echo " 请检查: 1) 后端地址是否正确 2) 该平台是否有对应二进制"
|
||||||
|
if [ "$OS_LOWER" = "darwin" ]; then
|
||||||
|
echo " 提示: macOS 二进制需要后端编译 darwin 版本 (make build-darwin-arm64)"
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
chmod +x /tmp/aiops-agent
|
chmod +x /tmp/aiops-agent
|
||||||
@@ -88,13 +113,79 @@ mv /tmp/aiops-agent "${INSTALL_DIR}/aiops-agent"
|
|||||||
|
|
||||||
echo "✓ 安装到 ${INSTALL_DIR}/aiops-agent"
|
echo "✓ 安装到 ${INSTALL_DIR}/aiops-agent"
|
||||||
|
|
||||||
# 配置 systemd 服务
|
|
||||||
if [ -z "$AGENT_TOKEN" ]; then
|
if [ -z "$AGENT_TOKEN" ]; then
|
||||||
echo "⚠ 未提供 --token,使用空令牌"
|
echo "⚠ 未提供 --token,使用空令牌"
|
||||||
AGENT_TOKEN="none"
|
AGENT_TOKEN="none"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF
|
# ─── 平台分支: Linux(systemd) / macOS(launchd) ───
|
||||||
|
|
||||||
|
if [ "$OS_LOWER" = "darwin" ]; then
|
||||||
|
# ===== macOS: launchd 服务 =====
|
||||||
|
PLIST_PATH="/Library/LaunchDaemons/${PLIST_LABEL}.plist"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$PLIST_PATH")"
|
||||||
|
|
||||||
|
cat > "$PLIST_PATH" << EOF
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>${PLIST_LABEL}</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>${INSTALL_DIR}/aiops-agent</string>
|
||||||
|
<string>-server</string>
|
||||||
|
<string>${AIOPS_SERVER}</string>
|
||||||
|
<string>-interval</string>
|
||||||
|
<string>10</string>
|
||||||
|
<string>-token</string>
|
||||||
|
<string>${AGENT_TOKEN}</string>
|
||||||
|
</array>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/var/log/aiops-agent.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/var/log/aiops-agent.err.log</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "✓ 创建 launchd 服务 ($PLIST_PATH)"
|
||||||
|
|
||||||
|
# 停止旧实例(如果存在)
|
||||||
|
launchctl unload "$PLIST_PATH" 2>/dev/null || true
|
||||||
|
# 加载并启动
|
||||||
|
launchctl load -w "$PLIST_PATH"
|
||||||
|
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# 检查运行状态
|
||||||
|
if launchctl list | grep -q "$PLIST_LABEL"; then
|
||||||
|
echo ""
|
||||||
|
echo "✅ 安装成功!Agent 已启动并设置为开机自启"
|
||||||
|
echo ""
|
||||||
|
echo " 查看状态: launchctl list | grep aiops"
|
||||||
|
echo " 查看日志: tail -f /var/log/aiops-agent.log"
|
||||||
|
echo " 手动停止: launchctl unload $PLIST_PATH"
|
||||||
|
echo " 手动启动: launchctl load -w $PLIST_PATH"
|
||||||
|
echo " 卸载: launchctl unload $PLIST_PATH && rm -f ${INSTALL_DIR}/aiops-agent $PLIST_PATH"
|
||||||
|
echo ""
|
||||||
|
else
|
||||||
|
echo "❌ Agent 启动失败,请检查日志:"
|
||||||
|
echo " cat /var/log/aiops-agent.err.log"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
else
|
||||||
|
# ===== Linux: systemd 服务 =====
|
||||||
|
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
|
||||||
|
|
||||||
|
cat > "$SERVICE_FILE" << EOF
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=AIOps Monitoring Agent
|
Description=AIOps Monitoring Agent
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
@@ -111,25 +202,26 @@ LimitNOFILE=65536
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "✓ 创建 systemd 服务"
|
echo "✓ 创建 systemd 服务"
|
||||||
|
|
||||||
# 启动服务
|
# 启动服务
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable ${SERVICE_NAME}
|
systemctl enable ${SERVICE_NAME}
|
||||||
systemctl restart ${SERVICE_NAME}
|
systemctl restart ${SERVICE_NAME}
|
||||||
|
|
||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
if systemctl is-active --quiet ${SERVICE_NAME}; then
|
if systemctl is-active --quiet ${SERVICE_NAME}; then
|
||||||
echo ""
|
echo ""
|
||||||
echo "✅ 安装成功!Agent 已启动并设置为开机自启"
|
echo "✅ 安装成功!Agent 已启动并设置为开机自启"
|
||||||
echo ""
|
echo ""
|
||||||
echo " 查看状态: systemctl status ${SERVICE_NAME}"
|
echo " 查看状态: systemctl status ${SERVICE_NAME}"
|
||||||
echo " 查看日志: journalctl -u ${SERVICE_NAME} -f"
|
echo " 查看日志: journalctl -u ${SERVICE_NAME} -f"
|
||||||
echo " 卸载: ${0} --uninstall || systemctl stop ${SERVICE_NAME} && rm -f ${INSTALL_DIR}/aiops-agent /etc/systemd/system/${SERVICE_NAME}.service && systemctl daemon-reload"
|
echo " 卸载: systemctl stop ${SERVICE_NAME} && rm -f ${INSTALL_DIR}/aiops-agent ${SERVICE_FILE} && systemctl daemon-reload"
|
||||||
echo ""
|
echo ""
|
||||||
else
|
else
|
||||||
echo "❌ Agent 启动失败,请检查日志:"
|
echo "❌ Agent 启动失败,请检查日志:"
|
||||||
echo " journalctl -u ${SERVICE_NAME} -n 20"
|
echo " journalctl -u ${SERVICE_NAME} -n 20"
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -349,3 +349,39 @@ func (h *K8sHandler) ListIngresses(c *gin.Context) {
|
|||||||
Data: ingresses,
|
Data: ingresses,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetTopology 获取集群拓扑布局 // Logan.Li
|
||||||
|
func (h *K8sHandler) GetTopology(c *gin.Context) {
|
||||||
|
clusterID := c.Param("id")
|
||||||
|
var layout models.TopologyLayout
|
||||||
|
result := h.db.Where("cluster_id = ?", clusterID).First(&layout)
|
||||||
|
if result.Error != nil {
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Data: &models.TopologyLayout{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Data: layout})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveTopology 保存集群拓扑布局 // Logan.Li
|
||||||
|
func (h *K8sHandler) SaveTopology(c *gin.Context) {
|
||||||
|
clusterID, _ := strconv.Atoi(c.Param("id"))
|
||||||
|
var req struct {
|
||||||
|
LayoutData string `json:"layout_data"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "参数错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var layout models.TopologyLayout
|
||||||
|
h.db.Where("cluster_id = ?", clusterID).First(&layout)
|
||||||
|
layout.ClusterID = uint(clusterID)
|
||||||
|
layout.LayoutData = req.LayoutData
|
||||||
|
if layout.ID == 0 {
|
||||||
|
h.db.Create(&layout)
|
||||||
|
} else {
|
||||||
|
h.db.Save(&layout)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "保存成功"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"ezops/internal/models"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateRole 创建角色 // Logan.Li
|
||||||
|
// POST /api/v1/admin/roles
|
||||||
|
func (h *UserHandler) CreateRole(c *gin.Context) {
|
||||||
|
var req models.CreateRoleRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{
|
||||||
|
Code: 400,
|
||||||
|
Message: "请求参数错误: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 名称 / 编码唯一性校验
|
||||||
|
var existing models.Role
|
||||||
|
if err := h.db.Where("name = ? OR code = ?", req.Name, req.Code).First(&existing).Error; err == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{
|
||||||
|
Code: 400,
|
||||||
|
Message: "角色名称或编码已存在",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
role := models.Role{
|
||||||
|
Name: req.Name,
|
||||||
|
Code: req.Code,
|
||||||
|
Description: req.Description,
|
||||||
|
Status: 1,
|
||||||
|
}
|
||||||
|
if err := h.db.Create(&role).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{
|
||||||
|
Code: 500,
|
||||||
|
Message: "创建角色失败",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关联权限
|
||||||
|
if len(req.PermissionIDs) > 0 {
|
||||||
|
var perms []models.Permission
|
||||||
|
h.db.Find(&perms, req.PermissionIDs)
|
||||||
|
h.db.Model(&role).Association("Permissions").Append(perms)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.db.Preload("Permissions").First(&role, role.ID)
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: "创建成功",
|
||||||
|
Data: role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRole 更新角色 // Logan.Li
|
||||||
|
// PUT /api/v1/admin/roles/:id
|
||||||
|
func (h *UserHandler) UpdateRole(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的角色 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req models.UpdateRoleRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{
|
||||||
|
Code: 400,
|
||||||
|
Message: "请求参数错误: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var role models.Role
|
||||||
|
if err := h.db.First(&role, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "角色不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]interface{}{}
|
||||||
|
if req.Name != "" {
|
||||||
|
// 名称 / 编码唯一性校验(排除自身)
|
||||||
|
var dup models.Role
|
||||||
|
if err := h.db.Where("(name = ? OR code = ?) AND id != ?", req.Name, req.Code, id).First(&dup).Error; err == nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "角色名称或编码已存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updates["name"] = req.Name
|
||||||
|
}
|
||||||
|
if req.Code != "" {
|
||||||
|
updates["code"] = req.Code
|
||||||
|
}
|
||||||
|
if req.Description != nil {
|
||||||
|
updates["description"] = *req.Description
|
||||||
|
}
|
||||||
|
if req.Status != nil {
|
||||||
|
updates["status"] = *req.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := h.db.Model(&role).Updates(updates).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "更新失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 替换权限关联
|
||||||
|
if req.PermissionIDs != nil {
|
||||||
|
var perms []models.Permission
|
||||||
|
if len(req.PermissionIDs) > 0 {
|
||||||
|
h.db.Find(&perms, req.PermissionIDs)
|
||||||
|
}
|
||||||
|
h.db.Model(&role).Association("Permissions").Replace(perms)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.db.Preload("Permissions").First(&role, id)
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: "更新成功",
|
||||||
|
Data: role,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteRole 删除角色 // Logan.Li
|
||||||
|
// DELETE /api/v1/admin/roles/:id
|
||||||
|
func (h *UserHandler) DeleteRole(c *gin.Context) {
|
||||||
|
id, err := strconv.Atoi(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: "无效的角色 ID"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var role models.Role
|
||||||
|
if err := h.db.First(&role, id).Error; err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, models.Response{Code: 404, Message: "角色不存在"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空权限关联后再删除(避免 join 表残留)
|
||||||
|
h.db.Model(&role).Association("Permissions").Clear()
|
||||||
|
if err := h.db.Delete(&role).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, models.Response{Code: 500, Message: "删除失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Message: "删除成功",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPermissions 获取权限列表(供前端角色权限选择) // Logan.Li
|
||||||
|
// GET /api/v1/admin/permissions
|
||||||
|
func (h *UserHandler) ListPermissions(c *gin.Context) {
|
||||||
|
var perms []models.Permission
|
||||||
|
h.db.Where("status = ?", 1).Order("sort ASC, id ASC").Find(&perms)
|
||||||
|
c.JSON(http.StatusOK, models.Response{
|
||||||
|
Code: 200,
|
||||||
|
Data: models.ListResponse{List: perms, Total: int64(len(perms))},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -341,7 +341,7 @@ func (h *UserHandler) BatchImportUsers(c *gin.Context) {
|
|||||||
// ListRoles 获取角色列表(供前端下拉选择) // Logan.Li
|
// ListRoles 获取角色列表(供前端下拉选择) // Logan.Li
|
||||||
func (h *UserHandler) ListRoles(c *gin.Context) {
|
func (h *UserHandler) ListRoles(c *gin.Context) {
|
||||||
var roles []models.Role
|
var roles []models.Role
|
||||||
h.db.Where("status = ?", 1).Order("id ASC").Find(&roles)
|
h.db.Where("status = ?", 1).Preload("Permissions").Order("id ASC").Find(&roles)
|
||||||
c.JSON(http.StatusOK, models.Response{
|
c.JSON(http.StatusOK, models.Response{
|
||||||
Code: 200, Data: models.ListResponse{List: roles, Total: int64(len(roles))},
|
Code: 200, Data: models.ListResponse{List: roles, Total: int64(len(roles))},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,14 +26,16 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) {
|
|||||||
r.StaticFile("/agent/install.sh", "./agent/deploy/install.sh")
|
r.StaticFile("/agent/install.sh", "./agent/deploy/install.sh")
|
||||||
r.StaticFile("/agent/aiops-agent-linux-amd64", "./agent/bin/aiops-agent-linux-amd64")
|
r.StaticFile("/agent/aiops-agent-linux-amd64", "./agent/bin/aiops-agent-linux-amd64")
|
||||||
r.StaticFile("/agent/aiops-agent-linux-arm64", "./agent/bin/aiops-agent-linux-arm64")
|
r.StaticFile("/agent/aiops-agent-linux-arm64", "./agent/bin/aiops-agent-linux-arm64")
|
||||||
|
r.StaticFile("/agent/aiops-agent-darwin-arm64", "./agent/bin/aiops-agent-darwin-arm64") // macOS Apple Silicon // Logan.Li
|
||||||
r.GET("/agent/arch", func(c *gin.Context) {
|
r.GET("/agent/arch", func(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"downloads": gin.H{
|
"downloads": gin.H{
|
||||||
"amd64": "/agent/aiops-agent-linux-amd64",
|
"linux-amd64": "/agent/aiops-agent-linux-amd64",
|
||||||
"arm64": "/agent/aiops-agent-linux-arm64",
|
"linux-arm64": "/agent/aiops-agent-linux-arm64",
|
||||||
|
"darwin-arm64": "/agent/aiops-agent-darwin-arm64",
|
||||||
},
|
},
|
||||||
"install": "curl -fsSL http://<server>:8080/agent/install.sh | bash -s -- --server http://<server>:8080 --token <token>",
|
"install": "curl -fsSL http://<server>:8080/agent/install.sh | sudo bash -s -- --server http://<server>:8080 --token <token>",
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -70,6 +72,15 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) {
|
|||||||
admin.DELETE("/users/:id", authHandler.DeleteUser)
|
admin.DELETE("/users/:id", authHandler.DeleteUser)
|
||||||
admin.POST("/users/batch-import", authHandler.BatchImportUsers)
|
admin.POST("/users/batch-import", authHandler.BatchImportUsers)
|
||||||
admin.GET("/users/import-template", authHandler.ImportTemplate)
|
admin.GET("/users/import-template", authHandler.ImportTemplate)
|
||||||
|
|
||||||
|
// 角色管理路由 // Logan.Li
|
||||||
|
admin.GET("/roles", authHandler.ListRoles)
|
||||||
|
admin.POST("/roles", authHandler.CreateRole)
|
||||||
|
admin.PUT("/roles/:id", authHandler.UpdateRole)
|
||||||
|
admin.DELETE("/roles/:id", authHandler.DeleteRole)
|
||||||
|
|
||||||
|
// 权限列表(供角色权限选择)
|
||||||
|
admin.GET("/permissions", authHandler.ListPermissions)
|
||||||
}
|
}
|
||||||
|
|
||||||
// LDAP 配置路由
|
// LDAP 配置路由
|
||||||
@@ -198,6 +209,8 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) {
|
|||||||
clusters.GET("/:id/deployments", k8sHandler.ListDeployments)
|
clusters.GET("/:id/deployments", k8sHandler.ListDeployments)
|
||||||
clusters.GET("/:id/services", k8sHandler.ListServices)
|
clusters.GET("/:id/services", k8sHandler.ListServices)
|
||||||
clusters.GET("/:id/ingresses", k8sHandler.ListIngresses)
|
clusters.GET("/:id/ingresses", k8sHandler.ListIngresses)
|
||||||
|
clusters.GET("/:id/topology", k8sHandler.GetTopology) // 获取拓扑布局 // Logan.Li
|
||||||
|
clusters.PUT("/:id/topology", k8sHandler.SaveTopology) // 保存拓扑布局 // Logan.Li
|
||||||
}
|
}
|
||||||
|
|
||||||
// 告警路由
|
// 告警路由
|
||||||
|
|||||||
@@ -193,3 +193,12 @@ type K8sClusterStats struct {
|
|||||||
TotalPods int64 `json:"total_pods"`
|
TotalPods int64 `json:"total_pods"`
|
||||||
TotalServices int64 `json:"total_services"`
|
TotalServices int64 `json:"total_services"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TopologyLayout 拓扑布局(保存节点位置 + 自定义节点 + 自定义连线) // Logan.Li
|
||||||
|
type TopologyLayout struct {
|
||||||
|
ID uint `gorm:"primarykey" json:"id"`
|
||||||
|
ClusterID uint `gorm:"uniqueIndex;not null" json:"cluster_id"`
|
||||||
|
LayoutData string `gorm:"type:text" json:"layout_data"` // JSON: {positions, customNodes, customEdges}
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
@@ -105,6 +105,23 @@ type UpdateUserRequest struct {
|
|||||||
RoleIDs []uint `json:"role_ids"`
|
RoleIDs []uint `json:"role_ids"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateRoleRequest 创建角色请求 // Logan.Li
|
||||||
|
type CreateRoleRequest struct {
|
||||||
|
Name string `json:"name" binding:"required,min=1,max=64"`
|
||||||
|
Code string `json:"code" binding:"required,min=1,max=64"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
PermissionIDs []uint `json:"permission_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateRoleRequest 更新角色请求 // Logan.Li
|
||||||
|
type UpdateRoleRequest struct {
|
||||||
|
Name string `json:"name" binding:"omitempty,min=1,max=64"`
|
||||||
|
Code string `json:"code" binding:"omitempty,min=1,max=64"`
|
||||||
|
Description *string `json:"description"`
|
||||||
|
Status *int `json:"status"`
|
||||||
|
PermissionIDs []uint `json:"permission_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
// ChangePasswordRequest 修改密码请求
|
// ChangePasswordRequest 修改密码请求
|
||||||
type ChangePasswordRequest struct {
|
type ChangePasswordRequest struct {
|
||||||
OldPassword string `json:"old_password" binding:"required"`
|
OldPassword string `json:"old_password" binding:"required"`
|
||||||
|
|||||||
@@ -102,5 +102,6 @@ func AutoMigrate(db *gorm.DB) error {
|
|||||||
&models.LogEntry{},
|
&models.LogEntry{},
|
||||||
&models.SQLAuditRecord{},
|
&models.SQLAuditRecord{},
|
||||||
&models.SQLAuditRule{},
|
&models.SQLAuditRule{},
|
||||||
|
&models.TopologyLayout{}, // 拓扑布局保存 // Logan.Li
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -284,7 +284,7 @@ export function RegionManager() {
|
|||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-slate-400">{r.location || '-'}</TableCell>
|
<TableCell className="text-slate-400">{r.location || '-'}</TableCell>
|
||||||
<TableCell className="text-slate-400 font-mono text-xs">{r.endpoint || '-'}</TableCell>
|
<TableCell className="max-w-[200px] truncate font-mono text-xs text-slate-400" title={r.endpoint}>{r.endpoint || '-'}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{r.status === 'active' ? (
|
{r.status === 'active' ? (
|
||||||
<Badge className="bg-emerald-500/20 text-emerald-400 gap-1">
|
<Badge className="bg-emerald-500/20 text-emerald-400 gap-1">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -30,6 +31,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import {
|
import {
|
||||||
Rocket,
|
Rocket,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -53,14 +56,26 @@ import {
|
|||||||
Terminal,
|
Terminal,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { releaseApi } from '@/services/api';
|
import { releaseApi } from '@/services/api';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export function AppReleaseManager() {
|
export function AppReleaseManager() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [envFilter, setEnvFilter] = useState<string>('all');
|
const [envFilter, setEnvFilter] = useState<string>('all');
|
||||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||||
const [releases, setReleases] = useState<any[]>([]);
|
const [releases, setReleases] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [selectedRelease, setSelectedRelease] = useState<any | null>(null);
|
const [selectedRelease, setSelectedRelease] = useState<any | null>(null);
|
||||||
|
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [newRelease, setNewRelease] = useState({
|
||||||
|
appName: '',
|
||||||
|
version: '',
|
||||||
|
environment: 'dev',
|
||||||
|
commitId: '',
|
||||||
|
commitMsg: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -165,6 +180,33 @@ export function AppReleaseManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 新建发布提交(复用 releaseApi.createRelease) // Logan.Li
|
||||||
|
const handleCreateRelease = async () => {
|
||||||
|
if (!newRelease.appName.trim() || !newRelease.version.trim()) {
|
||||||
|
toast.error('请填写应用名称和版本号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await releaseApi.createRelease({
|
||||||
|
app_name: newRelease.appName.trim(),
|
||||||
|
version: newRelease.version.trim(),
|
||||||
|
environment: newRelease.environment,
|
||||||
|
commit_id: newRelease.commitId.trim(),
|
||||||
|
commit_msg: newRelease.commitMsg.trim(),
|
||||||
|
description: newRelease.description.trim(),
|
||||||
|
});
|
||||||
|
toast.success('发布申请已提交');
|
||||||
|
setIsCreateDialogOpen(false);
|
||||||
|
setNewRelease({ appName: '', version: '', environment: 'dev', commitId: '', commitMsg: '', description: '' });
|
||||||
|
loadData();
|
||||||
|
} catch {
|
||||||
|
toast.error('创建失败');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 模拟流水线步骤
|
// 模拟流水线步骤
|
||||||
const pipelineSteps = [
|
const pipelineSteps = [
|
||||||
{ name: '代码检出', status: 'success', duration: '15s' },
|
{ name: '代码检出', status: 'success', duration: '15s' },
|
||||||
@@ -186,11 +228,18 @@ export function AppReleaseManager() {
|
|||||||
<p className="text-sm text-slate-500 mt-1">CI/CD流水线管理与发布历史</p>
|
<p className="text-sm text-slate-500 mt-1">CI/CD流水线管理与发布历史</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
|
||||||
|
onClick={() => navigate('/pipelines')}
|
||||||
|
>
|
||||||
<Settings className="h-4 w-4 mr-2" />
|
<Settings className="h-4 w-4 mr-2" />
|
||||||
流水线配置
|
流水线配置
|
||||||
</Button>
|
</Button>
|
||||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
<Button
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 text-white"
|
||||||
|
onClick={() => setIsCreateDialogOpen(true)}
|
||||||
|
>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
新建发布
|
新建发布
|
||||||
</Button>
|
</Button>
|
||||||
@@ -485,6 +534,93 @@ export function AppReleaseManager() {
|
|||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Create Release Dialog */}
|
||||||
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
|
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-slate-100">新建发布</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-500">创建新的应用版本发布申请</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-slate-300">应用名称</Label>
|
||||||
|
<Input
|
||||||
|
value={newRelease.appName}
|
||||||
|
onChange={(e) => setNewRelease({ ...newRelease, appName: e.target.value })}
|
||||||
|
placeholder="如:用户中心服务"
|
||||||
|
className="mt-1 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-slate-300">版本号</Label>
|
||||||
|
<Input
|
||||||
|
value={newRelease.version}
|
||||||
|
onChange={(e) => setNewRelease({ ...newRelease, version: e.target.value })}
|
||||||
|
placeholder="v1.0.0"
|
||||||
|
className="mt-1 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-slate-300">目标环境</Label>
|
||||||
|
<Select
|
||||||
|
value={newRelease.environment}
|
||||||
|
onValueChange={(value) => setNewRelease({ ...newRelease, environment: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="mt-1 bg-slate-800/50 border-slate-700 text-slate-200">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="bg-slate-800 border-slate-700">
|
||||||
|
<SelectItem value="dev">开发环境</SelectItem>
|
||||||
|
<SelectItem value="test">测试环境</SelectItem>
|
||||||
|
<SelectItem value="staging">预发布</SelectItem>
|
||||||
|
<SelectItem value="prod">生产环境</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<Label className="text-slate-300">Commit ID</Label>
|
||||||
|
<Input
|
||||||
|
value={newRelease.commitId}
|
||||||
|
onChange={(e) => setNewRelease({ ...newRelease, commitId: e.target.value })}
|
||||||
|
placeholder="abc1234"
|
||||||
|
className="mt-1 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-slate-300">提交信息</Label>
|
||||||
|
<Input
|
||||||
|
value={newRelease.commitMsg}
|
||||||
|
onChange={(e) => setNewRelease({ ...newRelease, commitMsg: e.target.value })}
|
||||||
|
placeholder="feat: 新增功能"
|
||||||
|
className="mt-1 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label className="text-slate-300">发布说明</Label>
|
||||||
|
<Textarea
|
||||||
|
value={newRelease.description}
|
||||||
|
onChange={(e) => setNewRelease({ ...newRelease, description: e.target.value })}
|
||||||
|
placeholder="描述本次发布的内容..."
|
||||||
|
className="mt-1 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setIsCreateDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleCreateRelease} disabled={submitting}>
|
||||||
|
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Plus className="h-4 w-4 mr-2" />}
|
||||||
|
提交申请
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,175 +2,29 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import {
|
import {
|
||||||
Network,
|
Search, RefreshCw, Loader2, Cloud, Save, Plus, Trash2, Link2, Pencil, RotateCcw,
|
||||||
Search,
|
|
||||||
Server,
|
|
||||||
Share2,
|
|
||||||
Cloud,
|
|
||||||
RefreshCw,
|
|
||||||
Loader2,
|
|
||||||
Globe,
|
|
||||||
Boxes,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { k8sApi } from '@/services/api';
|
import { k8sApi } from '@/services/api';
|
||||||
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import type { TopologyNode, TopologyEdge } from '@/types';
|
import type { TopologyNode, TopologyEdge } from '@/types';
|
||||||
|
import {
|
||||||
// K8s resource type → topology node type label
|
buildTopology, getNodeIcon, getNodeColor, NODE_TYPE_LABELS, CUSTOM_NODE_TYPES,
|
||||||
const NODE_TYPE_LABELS: Record<string, string> = {
|
isCustomNode, type TopologyLayoutData,
|
||||||
cluster: '集群',
|
} from './topology-helpers';
|
||||||
loadbalancer: 'Ingress',
|
|
||||||
service: 'Service',
|
|
||||||
app: 'Deployment',
|
|
||||||
server: 'Node',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* buildTopology — 从真实 K8s 资源构建分层拓扑图
|
|
||||||
*
|
|
||||||
* 层级 (自上而下): Cluster → Ingress → Service → Deployment → Node
|
|
||||||
*/
|
|
||||||
function buildTopology(
|
|
||||||
cluster: any,
|
|
||||||
ingresses: any[],
|
|
||||||
services: any[],
|
|
||||||
deployments: any[],
|
|
||||||
nodes: any[],
|
|
||||||
): { nodes: TopologyNode[]; edges: TopologyEdge[] } {
|
|
||||||
const tNodes: TopologyNode[] = [];
|
|
||||||
const tEdges: TopologyEdge[] = [];
|
|
||||||
const MAX_PER_LAYER = 12;
|
|
||||||
|
|
||||||
const maxLayerCount = Math.max(1, ingresses.length, services.length, deployments.length, nodes.length);
|
|
||||||
const canvasW = Math.max(900, Math.min(maxLayerCount * 130, 2400));
|
|
||||||
const layerY = [60, 180, 320, 480, 640];
|
|
||||||
|
|
||||||
const distributeX = (count: number): number[] => {
|
|
||||||
const cap = Math.min(count, MAX_PER_LAYER);
|
|
||||||
if (cap === 0) return [];
|
|
||||||
if (cap === 1) return [canvasW / 2];
|
|
||||||
const step = canvasW / (cap + 1);
|
|
||||||
return Array.from({ length: cap }, (_, i) => step * (i + 1));
|
|
||||||
};
|
|
||||||
|
|
||||||
// Layer 0: Cluster
|
|
||||||
const clusterId = `cluster-${cluster.id}`;
|
|
||||||
tNodes.push({
|
|
||||||
id: clusterId,
|
|
||||||
name: cluster.name || '集群',
|
|
||||||
type: 'cluster',
|
|
||||||
x: canvasW / 2,
|
|
||||||
y: layerY[0],
|
|
||||||
data: {
|
|
||||||
kind: 'cluster',
|
|
||||||
version: cluster.version,
|
|
||||||
status: cluster.status,
|
|
||||||
api_server: cluster.api_server,
|
|
||||||
node_count: cluster.node_count,
|
|
||||||
pod_count: cluster.pod_count,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Layer 1: Ingress
|
|
||||||
const ingCap = ingresses.slice(0, MAX_PER_LAYER);
|
|
||||||
const ingXs = distributeX(ingCap.length);
|
|
||||||
ingCap.forEach((ing, i) => {
|
|
||||||
const id = `ingress-${ing.id}`;
|
|
||||||
tNodes.push({ id, name: ing.name, type: 'loadbalancer', x: ingXs[i], y: layerY[1], data: { kind: 'ingress', namespace: ing.namespace, backend: ing.backend, tls: ing.tls, hosts: ing.hosts } });
|
|
||||||
tEdges.push({ id: `e-${clusterId}-${id}`, source: clusterId, target: id, label: 'route' });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Layer 2: Service
|
|
||||||
const svcCap = services.slice(0, MAX_PER_LAYER);
|
|
||||||
const svcXs = distributeX(svcCap.length);
|
|
||||||
svcCap.forEach((svc, i) => {
|
|
||||||
const id = `service-${svc.id}`;
|
|
||||||
tNodes.push({ id, name: svc.name, type: 'service', x: svcXs[i], y: layerY[2], data: { kind: 'service', namespace: svc.namespace, type: svc.type, cluster_ip: svc.cluster_ip, external_ip: svc.external_ip, selector: svc.selector } });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ingress → Service edges
|
|
||||||
if (ingCap.length > 0 && svcCap.length > 0) {
|
|
||||||
ingCap.forEach((ing) => {
|
|
||||||
const ingNodeId = `ingress-${ing.id}`;
|
|
||||||
const backendSvc = (ing.backend || '').split(':')[0].trim();
|
|
||||||
let matched = svcCap.filter((s) => s.name === backendSvc);
|
|
||||||
if (matched.length === 0) matched = svcCap.filter((s) => s.namespace === ing.namespace);
|
|
||||||
if (matched.length === 0) matched = [svcCap[0]];
|
|
||||||
matched.forEach((svc) => tEdges.push({ id: `e-${ingNodeId}-service-${svc.id}`, source: ingNodeId, target: `service-${svc.id}`, label: '→' }));
|
|
||||||
});
|
|
||||||
} else if (svcCap.length > 0) {
|
|
||||||
svcCap.forEach((svc) => tEdges.push({ id: `e-${clusterId}-service-${svc.id}`, source: clusterId, target: `service-${svc.id}`, label: 'expose' }));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layer 3: Deployment
|
|
||||||
const depCap = deployments.slice(0, MAX_PER_LAYER);
|
|
||||||
const depXs = distributeX(depCap.length);
|
|
||||||
depCap.forEach((dep, i) => {
|
|
||||||
const id = `deploy-${dep.id}`;
|
|
||||||
tNodes.push({ id, name: dep.name, type: 'app', x: depXs[i], y: layerY[3], data: { kind: 'deployment', namespace: dep.namespace, replicas: dep.replicas, ready_replicas: dep.ready_replicas, available_replicas: dep.available_replicas, image: dep.image } });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Service → Deployment edges (selector match or namespace fallback)
|
|
||||||
if (svcCap.length > 0 && depCap.length > 0) {
|
|
||||||
svcCap.forEach((svc) => {
|
|
||||||
const svcNodeId = `service-${svc.id}`;
|
|
||||||
let matched: any[] = [];
|
|
||||||
try {
|
|
||||||
const selector = typeof svc.selector === 'string' ? JSON.parse(svc.selector || '{}') : (svc.selector || {});
|
|
||||||
const selectorEntries = Object.entries(selector);
|
|
||||||
if (selectorEntries.length > 0) {
|
|
||||||
matched = depCap.filter((d) => {
|
|
||||||
let labels: Record<string, string> = {};
|
|
||||||
try { labels = typeof d.labels === 'string' ? JSON.parse(d.labels || '{}') : (d.labels || {}); } catch { /* */ }
|
|
||||||
return selectorEntries.every(([k, v]) => labels[k] === v);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch { /* */ }
|
|
||||||
if (matched.length === 0) matched = depCap.filter((d) => d.name === svc.name);
|
|
||||||
if (matched.length === 0) matched = depCap.filter((d) => d.namespace === svc.namespace);
|
|
||||||
if (matched.length === 0) matched = [depCap[0]];
|
|
||||||
matched.slice(0, 4).forEach((dep) => tEdges.push({ id: `e-${svcNodeId}-deploy-${dep.id}`, source: svcNodeId, target: `deploy-${dep.id}`, label: 'select' }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layer 4: Node
|
|
||||||
const nodeCap = nodes.slice(0, MAX_PER_LAYER);
|
|
||||||
const nodeXs = distributeX(nodeCap.length);
|
|
||||||
nodeCap.forEach((n, i) => {
|
|
||||||
const id = `k8snode-${n.id}`;
|
|
||||||
tNodes.push({ id, name: n.name, type: 'server', x: nodeXs[i], y: layerY[4], data: { kind: 'node', ip: n.ip, role: n.role, status: n.status, os: n.os, cpu: n.cpu, memory: n.memory, cpu_usage: n.cpu_usage, memory_usage: n.memory_usage, pod_count: n.pod_count } });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Deployment → Node edges (Pods scheduled on worker nodes)
|
|
||||||
const workerNodes = nodeCap.filter((n) => (n.role || '').toLowerCase() === 'worker');
|
|
||||||
const schedulableNodes = workerNodes.length > 0 ? workerNodes : nodeCap;
|
|
||||||
if (depCap.length > 0 && schedulableNodes.length > 0) {
|
|
||||||
depCap.forEach((dep) => {
|
|
||||||
const depNodeId = `deploy-${dep.id}`;
|
|
||||||
schedulableNodes.slice(0, 3).forEach((n) => tEdges.push({ id: `e-${depNodeId}-k8snode-${n.id}`, source: depNodeId, target: `k8snode-${n.id}`, label: 'schedule' }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { nodes: tNodes, edges: tEdges };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TopologyView() {
|
export function TopologyView() {
|
||||||
|
const _confirm = useConfirm();
|
||||||
const [clusters, setClusters] = useState<any[]>([]);
|
const [clusters, setClusters] = useState<any[]>([]);
|
||||||
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
|
const [selectedClusterId, setSelectedClusterId] = useState<string>('');
|
||||||
const [topology, setTopology] = useState<{ nodes: TopologyNode[]; edges: TopologyEdge[] }>({ nodes: [], edges: [] });
|
const [topology, setTopology] = useState<{ nodes: TopologyNode[]; edges: TopologyEdge[] }>({ nodes: [], edges: [] });
|
||||||
@@ -182,23 +36,49 @@ export function TopologyView() {
|
|||||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 });
|
||||||
const svgRef = useRef<SVGSVGElement>(null);
|
const svgRef = useRef<SVGSVGElement>(null);
|
||||||
|
|
||||||
|
// 编辑模式状态 // Logan.Li
|
||||||
|
const [editMode, setEditMode] = useState(false);
|
||||||
|
const [connectMode, setConnectMode] = useState(false);
|
||||||
|
const [connectSource, setConnectSource] = useState<string | null>(null);
|
||||||
|
const [savedLayout, setSavedLayout] = useState<TopologyLayoutData | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [customIdCounter, setCustomIdCounter] = useState(1);
|
||||||
|
// 编辑节点 Dialog
|
||||||
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [editingNode, setEditingNode] = useState<TopologyNode | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState({ name: '', type: 'database', note: '' });
|
||||||
|
|
||||||
const loadClusters = useCallback(async () => {
|
const loadClusters = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res: any = await k8sApi.getClusters();
|
const res: any = await k8sApi.getClusters();
|
||||||
const list = res?.list || [];
|
const list = res?.list || [];
|
||||||
setClusters(list);
|
setClusters(list);
|
||||||
if (list.length > 0 && !selectedClusterId) {
|
if (list.length > 0 && !selectedClusterId) setSelectedClusterId(String(list[0].id));
|
||||||
setSelectedClusterId(String(list[0].id));
|
} catch { setClusters([]); }
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('加载集群列表失败:', e);
|
|
||||||
setClusters([]);
|
|
||||||
}
|
|
||||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadClusters(); }, [loadClusters]);
|
||||||
loadClusters();
|
|
||||||
}, [loadClusters]);
|
// 加载已保存的布局 // Logan.Li
|
||||||
|
const loadSavedLayout = useCallback(async (clusterId: string) => {
|
||||||
|
try {
|
||||||
|
const res: any = await k8sApi.getTopology(clusterId);
|
||||||
|
const layout = res as any;
|
||||||
|
if (layout?.layout_data) {
|
||||||
|
const data = JSON.parse(layout.layout_data) as TopologyLayoutData;
|
||||||
|
setSavedLayout(data);
|
||||||
|
if (data.customNodes?.length > 0) {
|
||||||
|
const maxId = Math.max(...data.customNodes.map((n) => {
|
||||||
|
const m = n.id.match(/custom-(\d+)/); return m ? parseInt(m[1]) : 0;
|
||||||
|
}));
|
||||||
|
setCustomIdCounter(maxId + 1);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
} catch { /* 无保存布局 */ }
|
||||||
|
setSavedLayout(null);
|
||||||
|
return null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadTopology = useCallback(async () => {
|
const loadTopology = useCallback(async () => {
|
||||||
if (!selectedClusterId) return;
|
if (!selectedClusterId) return;
|
||||||
@@ -212,67 +92,49 @@ export function TopologyView() {
|
|||||||
k8sApi.getIngresses(selectedClusterId).catch(() => []),
|
k8sApi.getIngresses(selectedClusterId).catch(() => []),
|
||||||
]);
|
]);
|
||||||
const cluster = clusters.find((c) => String(c.id) === selectedClusterId) || {};
|
const cluster = clusters.find((c) => String(c.id) === selectedClusterId) || {};
|
||||||
setTopology(buildTopology(cluster, toArray(ingRes), toArray(svcRes), toArray(depRes), toArray(nodesRes)));
|
const built = buildTopology(cluster, toArray(ingRes), toArray(svcRes), toArray(depRes), toArray(nodesRes));
|
||||||
} catch (e) {
|
|
||||||
console.error('加载拓扑数据失败:', e);
|
// 合并已保存布局
|
||||||
setTopology({ nodes: [], edges: [] });
|
const layout = await loadSavedLayout(selectedClusterId);
|
||||||
} finally {
|
if (layout) {
|
||||||
setLoading(false);
|
// 应用保存的节点位置
|
||||||
|
if (layout.positions) {
|
||||||
|
built.nodes = built.nodes.map((n) => {
|
||||||
|
const saved = layout.positions[n.id];
|
||||||
|
return saved ? { ...n, x: saved.x, y: saved.y } : n;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [selectedClusterId, clusters]);
|
// 追加自定义节点
|
||||||
|
if (layout.customNodes) built.nodes = [...built.nodes, ...layout.customNodes];
|
||||||
|
// 追加自定义连线
|
||||||
|
if (layout.customEdges) built.edges = [...built.edges, ...layout.customEdges];
|
||||||
|
}
|
||||||
|
setTopology(built);
|
||||||
|
} catch { setTopology({ nodes: [], edges: [] }); }
|
||||||
|
finally { setLoading(false); }
|
||||||
|
}, [selectedClusterId, clusters, loadSavedLayout]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => { loadTopology(); }, [loadTopology]);
|
||||||
loadTopology();
|
|
||||||
}, [loadTopology]);
|
|
||||||
|
|
||||||
|
// --- 过滤 ---
|
||||||
const filteredNodes = topology.nodes.filter(
|
const filteredNodes = topology.nodes.filter(
|
||||||
(node) =>
|
(n) => n.name.toLowerCase().includes(searchQuery.toLowerCase()) && (viewType === 'all' || n.type === viewType),
|
||||||
node.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
|
||||||
(viewType === 'all' || node.type === viewType),
|
|
||||||
);
|
);
|
||||||
const visibleIds = new Set(filteredNodes.map((n) => n.id));
|
const visibleIds = new Set(filteredNodes.map((n) => n.id));
|
||||||
|
|
||||||
const getNodeIcon = (type: string) => {
|
// --- 拖拽 ---
|
||||||
switch (type) {
|
|
||||||
case 'server': return <Server className="h-5 w-5" />;
|
|
||||||
case 'loadbalancer': return <Globe className="h-5 w-5" />;
|
|
||||||
case 'cluster': return <Cloud className="h-5 w-5" />;
|
|
||||||
case 'app': return <Boxes className="h-5 w-5" />;
|
|
||||||
case 'service': return <Network className="h-5 w-5" />;
|
|
||||||
default: return <Server className="h-5 w-5" />;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const getNodeColor = (type: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case 'server': return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
|
||||||
case 'loadbalancer': return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
|
|
||||||
case 'cluster': return 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30';
|
|
||||||
case 'app': return 'bg-rose-500/20 text-rose-400 border-rose-500/30';
|
|
||||||
case 'service': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30';
|
|
||||||
default: return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const maxX = topology.nodes.length > 0 ? Math.max(...topology.nodes.map((n) => n.x)) + 80 : 900;
|
|
||||||
const maxY = topology.nodes.length > 0 ? Math.max(...topology.nodes.map((n) => n.y)) + 80 : 700;
|
|
||||||
const canvasW = Math.max(maxX, 900);
|
|
||||||
const canvasH = Math.max(maxY, 700);
|
|
||||||
|
|
||||||
// --- 拖拽逻辑 ---
|
|
||||||
const getSvgPoint = (e: React.MouseEvent): { x: number; y: number } => {
|
const getSvgPoint = (e: React.MouseEvent): { x: number; y: number } => {
|
||||||
const svg = svgRef.current;
|
const svg = svgRef.current;
|
||||||
if (!svg) return { x: 0, y: 0 };
|
if (!svg) return { x: 0, y: 0 };
|
||||||
const pt = svg.createSVGPoint();
|
const pt = svg.createSVGPoint();
|
||||||
pt.x = e.clientX;
|
pt.x = e.clientX; pt.y = e.clientY;
|
||||||
pt.y = e.clientY;
|
|
||||||
const ctm = svg.getScreenCTM();
|
const ctm = svg.getScreenCTM();
|
||||||
if (!ctm) return { x: 0, y: 0 };
|
if (!ctm) return { x: 0, y: 0 };
|
||||||
const transformed = pt.matrixTransform(ctm.inverse());
|
return { x: pt.matrixTransform(ctm.inverse()).x, y: pt.matrixTransform(ctm.inverse()).y };
|
||||||
return { x: transformed.x, y: transformed.y };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNodeMouseDown = (e: React.MouseEvent, node: TopologyNode) => {
|
const handleNodeMouseDown = (e: React.MouseEvent, node: TopologyNode) => {
|
||||||
|
if (connectMode) return; // 连线模式下不拖拽
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const pt = getSvgPoint(e);
|
const pt = getSvgPoint(e);
|
||||||
setDragOffset({ x: pt.x - node.x, y: pt.y - node.y });
|
setDragOffset({ x: pt.x - node.x, y: pt.y - node.y });
|
||||||
@@ -284,16 +146,133 @@ export function TopologyView() {
|
|||||||
const pt = getSvgPoint(e);
|
const pt = getSvgPoint(e);
|
||||||
setTopology((prev) => ({
|
setTopology((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
nodes: prev.nodes.map((n) =>
|
nodes: prev.nodes.map((n) => n.id === draggingId ? { ...n, x: pt.x - dragOffset.x, y: pt.y - dragOffset.y } : n),
|
||||||
n.id === draggingId ? { ...n, x: pt.x - dragOffset.x, y: pt.y - dragOffset.y } : n,
|
|
||||||
),
|
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => setDraggingId(null);
|
||||||
setDraggingId(null);
|
|
||||||
|
// --- 节点点击(区分普通/编辑/连线模式)---
|
||||||
|
const handleNodeClick = (node: TopologyNode) => {
|
||||||
|
if (draggingId) return;
|
||||||
|
if (connectMode) {
|
||||||
|
if (!connectSource) {
|
||||||
|
setConnectSource(node.id);
|
||||||
|
toast.info(`已选择源节点: ${node.name},请点击目标节点`);
|
||||||
|
} else if (connectSource === node.id) {
|
||||||
|
toast.info('不能连接自身');
|
||||||
|
} else {
|
||||||
|
// 创建自定义连线
|
||||||
|
const newEdge: TopologyEdge = {
|
||||||
|
id: `custom-edge-${Date.now()}`,
|
||||||
|
source: connectSource, target: node.id, label: 'connect',
|
||||||
|
};
|
||||||
|
setTopology((prev) => ({ ...prev, edges: [...prev.edges, newEdge] }));
|
||||||
|
toast.success('连线已添加');
|
||||||
|
setConnectSource(null);
|
||||||
|
setConnectMode(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedNode(node);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- 编辑模式操作 ---
|
||||||
|
const handleAddNode = () => {
|
||||||
|
setEditingNode(null);
|
||||||
|
setEditForm({ name: '', type: 'database', note: '' });
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEditNode = (node: TopologyNode) => {
|
||||||
|
setEditingNode(node);
|
||||||
|
setEditForm({ name: node.name, type: node.type, note: (node.data as any)?.note || '' });
|
||||||
|
setEditDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveNode = () => {
|
||||||
|
if (!editForm.name.trim()) { toast.error('请输入节点名称'); return; }
|
||||||
|
if (editingNode) {
|
||||||
|
// 编辑现有节点
|
||||||
|
setTopology((prev) => ({
|
||||||
|
...prev,
|
||||||
|
nodes: prev.nodes.map((n) => n.id === editingNode.id
|
||||||
|
? { ...n, name: editForm.name, type: editForm.type, data: { ...n.data, note: editForm.note } }
|
||||||
|
: n),
|
||||||
|
}));
|
||||||
|
toast.success('节点已更新');
|
||||||
|
} else {
|
||||||
|
// 新增自定义节点
|
||||||
|
const id = `custom-${customIdCounter}`;
|
||||||
|
const newNode: TopologyNode = {
|
||||||
|
id, name: editForm.name, type: editForm.type,
|
||||||
|
x: canvasW / 2 + (Math.random() - 0.5) * 100,
|
||||||
|
y: canvasH / 2 + (Math.random() - 0.5) * 100,
|
||||||
|
data: { kind: 'custom', note: editForm.note },
|
||||||
|
};
|
||||||
|
setTopology((prev) => ({ ...prev, nodes: [...prev.nodes, newNode] }));
|
||||||
|
setCustomIdCounter((c) => c + 1);
|
||||||
|
toast.success('节点已添加');
|
||||||
|
}
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteNode = async (node: TopologyNode) => {
|
||||||
|
const ok = await _confirm({ title: '删除节点', description: `确定删除「${node.name}」?相关连线也会一并删除`, variant: 'destructive' });
|
||||||
|
if (!ok) return;
|
||||||
|
setTopology((prev) => ({
|
||||||
|
nodes: prev.nodes.filter((n) => n.id !== node.id),
|
||||||
|
edges: prev.edges.filter((e) => e.source !== node.id && e.target !== node.id),
|
||||||
|
}));
|
||||||
|
setSelectedNode(null);
|
||||||
|
toast.success('节点已删除');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteEdge = async (edge: TopologyEdge) => {
|
||||||
|
if (!edge.id.startsWith('custom-edge')) { toast.info('K8s 原生关系不可删除'); return; }
|
||||||
|
const ok = await _confirm({ title: '删除连线', description: '确定删除此自定义连线?', variant: 'destructive' });
|
||||||
|
if (!ok) return;
|
||||||
|
setTopology((prev) => ({ ...prev, edges: prev.edges.filter((e) => e.id !== edge.id) }));
|
||||||
|
toast.success('连线已删除');
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 保存/重置布局 ---
|
||||||
|
const handleSaveLayout = async () => {
|
||||||
|
if (!selectedClusterId) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const layoutData: TopologyLayoutData = {
|
||||||
|
positions: Object.fromEntries(topology.nodes.map((n) => [n.id, { x: n.x, y: n.y }])),
|
||||||
|
customNodes: topology.nodes.filter((n) => isCustomNode(n.id)),
|
||||||
|
customEdges: topology.edges.filter((e) => e.id.startsWith('custom-edge')),
|
||||||
|
};
|
||||||
|
await k8sApi.saveTopology(selectedClusterId, JSON.stringify(layoutData));
|
||||||
|
toast.success('布局已保存');
|
||||||
|
} catch { toast.error('保存失败'); }
|
||||||
|
finally { setSaving(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetLayout = async () => {
|
||||||
|
const ok = await _confirm({ title: '重置布局', description: '将清除已保存的布局并重新自动排列', variant: 'destructive' });
|
||||||
|
if (!ok) return;
|
||||||
|
try { await k8sApi.saveTopology(selectedClusterId, ''); } catch { /* */ }
|
||||||
|
setSavedLayout(null);
|
||||||
|
setEditMode(false);
|
||||||
|
setConnectMode(false);
|
||||||
|
setConnectSource(null);
|
||||||
|
await loadTopology();
|
||||||
|
toast.success('布局已重置');
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- 画布尺寸 ---
|
||||||
|
const maxX = topology.nodes.length > 0 ? Math.max(...topology.nodes.map((n) => n.x)) + 80 : 900;
|
||||||
|
const maxY = topology.nodes.length > 0 ? Math.max(...topology.nodes.map((n) => n.y)) + 80 : 700;
|
||||||
|
const canvasW = Math.max(maxX, 900);
|
||||||
|
const canvasH = Math.max(maxY, 700);
|
||||||
|
|
||||||
|
const countByType = (type: string) => topology.nodes.filter((n) => n.type === type).length;
|
||||||
|
|
||||||
|
// --- 渲染 SVG 拓扑 ---
|
||||||
const renderTopology = () => {
|
const renderTopology = () => {
|
||||||
if (topology.nodes.length === 0) return null;
|
if (topology.nodes.length === 0) return null;
|
||||||
const ys = [60, 180, 320, 480, 640];
|
const ys = [60, 180, 320, 480, 640];
|
||||||
@@ -306,7 +285,7 @@ export function TopologyView() {
|
|||||||
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#1e293b" strokeWidth="0.5" />
|
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#1e293b" strokeWidth="0.5" />
|
||||||
</pattern>
|
</pattern>
|
||||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="28" refY="3.5" orient="auto">
|
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="28" refY="3.5" orient="auto">
|
||||||
<polygon points="0 0, 10 3.5, 0 7" fill="#475569" />
|
<polygon points="0 0, 10 3.5, 0 7" fill={connectMode ? '#f59e0b' : '#475569'} />
|
||||||
</marker>
|
</marker>
|
||||||
</defs>
|
</defs>
|
||||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||||
@@ -317,28 +296,47 @@ export function TopologyView() {
|
|||||||
const source = topology.nodes.find((n) => n.id === edge.source);
|
const source = topology.nodes.find((n) => n.id === edge.source);
|
||||||
const target = topology.nodes.find((n) => n.id === edge.target);
|
const target = topology.nodes.find((n) => n.id === edge.target);
|
||||||
if (!source || !target || !visibleIds.has(source.id) || !visibleIds.has(target.id)) return null;
|
if (!source || !target || !visibleIds.has(source.id) || !visibleIds.has(target.id)) return null;
|
||||||
return <line key={edge.id} x1={source.x} y1={source.y} x2={target.x} y2={target.y} stroke="#475569" strokeWidth="1.5" markerEnd="url(#arrowhead)" opacity={0.6} />;
|
const isCustom = edge.id.startsWith('custom-edge');
|
||||||
|
return (
|
||||||
|
<g key={edge.id} className={editMode && isCustom ? 'cursor-pointer' : ''} onClick={() => { if (editMode && isCustom) handleDeleteEdge(edge); }}>
|
||||||
|
<line x1={source.x} y1={source.y} x2={target.x} y2={target.y}
|
||||||
|
stroke={isCustom ? '#f59e0b' : '#475569'} strokeWidth={isCustom ? 2 : 1.5}
|
||||||
|
strokeDasharray={isCustom ? '6 3' : undefined}
|
||||||
|
markerEnd="url(#arrowhead)" opacity={isCustom ? 0.8 : 0.6} />
|
||||||
|
{editMode && isCustom && (
|
||||||
|
<text x={(source.x + target.x) / 2} y={(source.y + target.y) / 2}
|
||||||
|
fill="#f59e0b" fontSize="10" textAnchor="middle" className="select-none pointer-events-none">✕ 删除</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
})}
|
})}
|
||||||
{filteredNodes.map((node) => (
|
{filteredNodes.map((node) => {
|
||||||
|
const isCustom = isCustomNode(node.id);
|
||||||
|
const isConnectSource = connectSource === node.id;
|
||||||
|
return (
|
||||||
<g key={node.id} transform={`translate(${node.x}, ${node.y})`}
|
<g key={node.id} transform={`translate(${node.x}, ${node.y})`}
|
||||||
className={draggingId === node.id ? 'cursor-grabbing' : 'cursor-grab'}
|
className={connectMode ? 'cursor-crosshair' : draggingId === node.id ? 'cursor-grabbing' : 'cursor-grab'}
|
||||||
onMouseDown={(e) => handleNodeMouseDown(e, node)}
|
onMouseDown={(e) => handleNodeMouseDown(e, node)}
|
||||||
onClick={() => { if (!draggingId) setSelectedNode(node); }}>
|
onClick={() => handleNodeClick(node)}
|
||||||
<circle r="28" fill="#0f172a" stroke={selectedNode?.id === node.id ? '#3b82f6' : '#334155'} strokeWidth={selectedNode?.id === node.id ? 3 : 2} className="transition-all hover:stroke-blue-500" />
|
onDoubleClick={() => { if (editMode) handleEditNode(node); }}>
|
||||||
|
<circle r="28" fill="#0f172a"
|
||||||
|
stroke={isConnectSource ? '#f59e0b' : selectedNode?.id === node.id ? '#3b82f6' : isCustom ? '#f59e0b' : '#334155'}
|
||||||
|
strokeWidth={isConnectSource || selectedNode?.id === node.id ? 3 : 2}
|
||||||
|
strokeDasharray={isCustom ? '4 2' : undefined}
|
||||||
|
className="transition-all hover:stroke-blue-500" />
|
||||||
<foreignObject x="-12" y="-12" width="24" height="24">
|
<foreignObject x="-12" y="-12" width="24" height="24">
|
||||||
<div className={`flex items-center justify-center w-full h-full ${getNodeColor(node.type).split(' ')[1]}`}>{getNodeIcon(node.type)}</div>
|
<div className={`flex items-center justify-center w-full h-full ${getNodeColor(node.type).split(' ')[1]}`}>{getNodeIcon(node.type)}</div>
|
||||||
</foreignObject>
|
</foreignObject>
|
||||||
<text y="45" fill="#94a3b8" fontSize="12" textAnchor="middle" className="font-medium select-none">
|
<text y="45" fill={isCustom ? '#f59e0b' : '#94a3b8'} fontSize="12" textAnchor="middle" className="font-medium select-none">
|
||||||
{node.name.length > 20 ? node.name.slice(0, 18) + '…' : node.name}
|
{node.name.length > 20 ? node.name.slice(0, 18) + '…' : node.name}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const countByType = (type: string) => topology.nodes.filter((n) => n.type === type).length;
|
|
||||||
|
|
||||||
const renderNodeDetailFields = (node: TopologyNode) => {
|
const renderNodeDetailFields = (node: TopologyNode) => {
|
||||||
const data = node.data || {};
|
const data = node.data || {};
|
||||||
const entries = Object.entries(data).filter(([k]) => k !== 'kind');
|
const entries = Object.entries(data).filter(([k]) => k !== 'kind');
|
||||||
@@ -348,7 +346,7 @@ export function TopologyView() {
|
|||||||
{entries.map(([key, val]) => (
|
{entries.map(([key, val]) => (
|
||||||
<div key={key} className="p-2 rounded-lg bg-slate-800/50">
|
<div key={key} className="p-2 rounded-lg bg-slate-800/50">
|
||||||
<p className="text-xs text-slate-500 mb-0.5">{key}</p>
|
<p className="text-xs text-slate-500 mb-0.5">{key}</p>
|
||||||
<p className="text-sm text-slate-200 truncate">{val === null || val === undefined ? '—' : String(val)}</p>
|
<p className="text-sm text-slate-200 truncate" title={String(val)}>{val === null || val === undefined ? '—' : String(val)}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -361,33 +359,58 @@ export function TopologyView() {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-slate-100">架构拓扑</h1>
|
<h1 className="text-2xl font-bold text-slate-100">架构拓扑</h1>
|
||||||
<p className="text-sm text-slate-500 mt-1">K8s 集群资源拓扑可视化 · 拖拽节点可调整布局</p>
|
<p className="text-sm text-slate-500 mt-1">
|
||||||
|
K8s 集群资源拓扑可视化 · 拖拽节点调整布局
|
||||||
|
{editMode && <span className="text-amber-400 ml-2">· 编辑模式</span>}
|
||||||
|
{connectMode && <span className="text-amber-400 ml-1">· 连线中{connectSource ? '(选择目标节点)' : '(选择源节点)'}</span>}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={selectedClusterId} onValueChange={(v) => { setSelectedClusterId(v); setSelectedNode(null); }}>
|
<Select value={selectedClusterId} onValueChange={(v) => { setSelectedClusterId(v); setSelectedNode(null); setEditMode(false); }}>
|
||||||
<SelectTrigger className="w-44 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
<SelectTrigger className="w-44 h-9 bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue placeholder="选择集群" /></SelectTrigger>
|
||||||
<SelectValue placeholder="选择集群" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="bg-slate-800 border-slate-700">
|
<SelectContent className="bg-slate-800 border-slate-700">
|
||||||
{clusters.map((c) => (
|
{clusters.map((c) => <SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>)}
|
||||||
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
<Button variant={editMode ? 'default' : 'outline'} size="sm" className="h-9"
|
||||||
|
onClick={() => { setEditMode(!editMode); setConnectMode(false); setConnectSource(null); }}>
|
||||||
|
<Pencil className="h-4 w-4 mr-1" />{editMode ? '退出编辑' : '编辑'}
|
||||||
|
</Button>
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400" onClick={loadTopology} disabled={loading}>
|
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400" onClick={loadTopology} disabled={loading}>
|
||||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 编辑模式工具栏 */}
|
||||||
|
{editMode && (
|
||||||
|
<div className="flex items-center gap-2 p-3 rounded-lg bg-slate-800/50 border border-slate-700">
|
||||||
|
<Button size="sm" variant="outline" className="border-slate-600 text-slate-200" onClick={handleAddNode}>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />添加节点
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant={connectMode ? 'default' : 'outline'} className={connectMode ? 'bg-amber-600 hover:bg-amber-700' : 'border-slate-600 text-slate-200'}
|
||||||
|
onClick={() => { setConnectMode(!connectMode); setConnectSource(null); }}>
|
||||||
|
<Link2 className="h-4 w-4 mr-1" />{connectMode ? '取消连线' : '添加连线'}
|
||||||
|
</Button>
|
||||||
|
<div className="h-5 w-px bg-slate-600" />
|
||||||
|
<Button size="sm" variant="outline" className="border-emerald-600/30 text-emerald-400 hover:bg-emerald-600/10" onClick={handleSaveLayout} disabled={saving}>
|
||||||
|
<Save className="h-4 w-4 mr-1" />{saving ? '保存中...' : '保存布局'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" className="border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={handleResetLayout}>
|
||||||
|
<RotateCcw className="h-4 w-4 mr-1" />重置布局
|
||||||
|
</Button>
|
||||||
|
<span className="text-xs text-slate-500 ml-auto">双击节点编辑 · 点击虚线删除 · 拖拽调整位置</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
{[
|
{[
|
||||||
{ label: '总节点数', value: topology.nodes.length, icon: <Server className="h-5 w-5" />, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
{ label: '总节点数', value: topology.nodes.length, icon: <Cloud className="h-5 w-5" />, color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||||
{ label: '连接数', value: topology.edges.length, icon: <Network className="h-5 w-5" />, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
{ label: '连接数', value: topology.edges.length, icon: <Link2 className="h-5 w-5" />, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||||
{ label: 'Ingress', value: countByType('loadbalancer'), icon: <Globe className="h-5 w-5" />, color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
{ label: 'Service', value: countByType('service'), icon: getNodeIcon('service'), color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
|
||||||
{ label: 'Service', value: countByType('service'), icon: <Network className="h-5 w-5" />, color: 'text-emerald-400', bg: 'bg-emerald-500/10' },
|
{ label: 'Deployment', value: countByType('app'), icon: getNodeIcon('app'), color: 'text-rose-400', bg: 'bg-rose-500/10' },
|
||||||
{ label: 'Deployment', value: countByType('app'), icon: <Boxes className="h-5 w-5" />, color: 'text-rose-400', bg: 'bg-rose-500/10' },
|
{ label: '自定义', value: topology.nodes.filter((n) => isCustomNode(n.id)).length, icon: getNodeIcon('custom'), color: 'text-amber-400', bg: 'bg-amber-500/10' },
|
||||||
].map((s) => (
|
].map((s) => (
|
||||||
<Card key={s.label} className="bg-slate-900 border-slate-800">
|
<Card key={s.label} className="bg-slate-900 border-slate-800">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
@@ -407,19 +430,13 @@ export function TopologyView() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative w-48">
|
<div className="relative w-48">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||||
<Input placeholder="搜索节点..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" />
|
<Input placeholder="搜索节点..." value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||||
</div>
|
</div>
|
||||||
<Select value={viewType} onValueChange={setViewType}>
|
<Select value={viewType} onValueChange={setViewType}>
|
||||||
<SelectTrigger className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
|
<SelectTrigger className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue placeholder="节点类型" /></SelectTrigger>
|
||||||
<SelectValue placeholder="节点类型" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent className="bg-slate-800 border-slate-700">
|
<SelectContent className="bg-slate-800 border-slate-700">
|
||||||
<SelectItem value="all">全部类型</SelectItem>
|
<SelectItem value="all">全部类型</SelectItem>
|
||||||
<SelectItem value="cluster">集群</SelectItem>
|
{Object.entries(NODE_TYPE_LABELS).map(([v, l]) => <SelectItem key={v} value={v}>{l}</SelectItem>)}
|
||||||
<SelectItem value="loadbalancer">Ingress</SelectItem>
|
|
||||||
<SelectItem value="service">Service</SelectItem>
|
|
||||||
<SelectItem value="app">Deployment</SelectItem>
|
|
||||||
<SelectItem value="server">Node</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -429,7 +446,7 @@ export function TopologyView() {
|
|||||||
<div className="lg:col-span-3">
|
<div className="lg:col-span-3">
|
||||||
<Card className="bg-slate-900 border-slate-800">
|
<Card className="bg-slate-900 border-slate-800">
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="h-[600px] overflow-auto">
|
<div className={`h-[600px] overflow-auto ${connectMode ? 'ring-2 ring-amber-500/30 rounded-lg' : ''}`}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="flex items-center justify-center h-full"><Loader2 className="h-8 w-8 animate-spin text-slate-600" /></div>
|
<div className="flex items-center justify-center h-full"><Loader2 className="h-8 w-8 animate-spin text-slate-600" /></div>
|
||||||
) : topology.nodes.length === 0 ? (
|
) : topology.nodes.length === 0 ? (
|
||||||
@@ -458,7 +475,7 @@ export function TopologyView() {
|
|||||||
<div className={`p-2 rounded-lg ${getNodeColor(node.type)}`}>{getNodeIcon(node.type)}</div>
|
<div className={`p-2 rounded-lg ${getNodeColor(node.type)}`}>{getNodeIcon(node.type)}</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-medium text-slate-200 truncate">{node.name}</p>
|
<p className="text-sm font-medium text-slate-200 truncate">{node.name}</p>
|
||||||
<p className="text-xs text-slate-500">{NODE_TYPE_LABELS[node.type] || node.type}</p>
|
<p className="text-xs text-slate-500">{NODE_TYPE_LABELS[node.type] || node.type}{isCustomNode(node.id) ? ' · 自定义' : ''}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
@@ -471,11 +488,12 @@ export function TopologyView() {
|
|||||||
|
|
||||||
{/* Node Detail Dialog */}
|
{/* Node Detail Dialog */}
|
||||||
<Dialog open={!!selectedNode} onOpenChange={() => setSelectedNode(null)}>
|
<Dialog open={!!selectedNode} onOpenChange={() => setSelectedNode(null)}>
|
||||||
<DialogContent className="bg-slate-900 border-slate-800">
|
<DialogContent className="bg-slate-900 border-slate-800 max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||||
<div className={`p-2 rounded-lg ${getNodeColor(selectedNode?.type || 'server')}`}>{selectedNode && getNodeIcon(selectedNode.type)}</div>
|
<div className={`p-2 rounded-lg ${getNodeColor(selectedNode?.type || 'server')}`}>{selectedNode && getNodeIcon(selectedNode.type)}</div>
|
||||||
{selectedNode?.name}
|
{selectedNode?.name}
|
||||||
|
{selectedNode && isCustomNode(selectedNode.id) && <Badge variant="outline" className="border-amber-500/30 text-amber-400 text-xs">自定义</Badge>}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription className="text-slate-500">类型: {NODE_TYPE_LABELS[selectedNode?.type || ''] || selectedNode?.type}</DialogDescription>
|
<DialogDescription className="text-slate-500">类型: {NODE_TYPE_LABELS[selectedNode?.type || ''] || selectedNode?.type}</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
@@ -487,23 +505,70 @@ export function TopologyView() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).map((edge) => {
|
{topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).map((edge) => {
|
||||||
const isSource = edge.source === selectedNode.id;
|
const isSource = edge.source === selectedNode.id;
|
||||||
const relatedNodeId = isSource ? edge.target : edge.source;
|
const related = topology.nodes.find((n) => n.id === (isSource ? edge.target : edge.source));
|
||||||
const relatedNode = topology.nodes.find((n) => n.id === relatedNodeId);
|
if (!related) return null;
|
||||||
if (!relatedNode) return null;
|
|
||||||
return (
|
return (
|
||||||
<div key={edge.id} className="flex items-center gap-2 p-2 rounded-lg bg-slate-800/50">
|
<div key={edge.id} className="flex items-center gap-2 p-2 rounded-lg bg-slate-800/50">
|
||||||
<span className="text-sm text-slate-400">{isSource ? '→' : '←'}</span>
|
<span className="text-sm text-slate-400">{isSource ? '→' : '←'}</span>
|
||||||
<div className={`p-1 rounded ${getNodeColor(relatedNode.type)}`}>{getNodeIcon(relatedNode.type)}</div>
|
<div className={`p-1 rounded ${getNodeColor(related.type)}`}>{getNodeIcon(related.type, 'h-4 w-4')}</div>
|
||||||
<span className="text-sm text-slate-300">{relatedNode.name}</span>
|
<span className="text-sm text-slate-300">{related.name}</span>
|
||||||
<Badge variant="outline" className="ml-auto border-slate-700 text-slate-400 text-xs">{edge.label}</Badge>
|
<Badge variant="outline" className="ml-auto border-slate-700 text-slate-400 text-xs">{edge.label}</Badge>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).length === 0 && <p className="text-xs text-slate-600">无关联连接</p>}
|
{topology.edges.filter((e) => e.source === selectedNode.id || e.target === selectedNode.id).length === 0 && <p className="text-xs text-slate-600">无关联连接</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 编辑模式操作按钮 */}
|
||||||
|
{editMode && selectedNode && (
|
||||||
|
<div className="flex gap-2 pt-2 border-t border-slate-700">
|
||||||
|
<Button size="sm" variant="outline" className="border-slate-600 text-slate-200" onClick={() => handleEditNode(selectedNode)}>
|
||||||
|
<Pencil className="h-3.5 w-3.5 mr-1" />编辑
|
||||||
|
</Button>
|
||||||
|
{isCustomNode(selectedNode.id) && (
|
||||||
|
<Button size="sm" variant="outline" className="border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={() => handleDeleteNode(selectedNode)}>
|
||||||
|
<Trash2 className="h-3.5 w-3.5 mr-1" />删除
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Edit/Add Node Dialog */}
|
||||||
|
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-800 max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-slate-100">{editingNode ? '编辑节点' : '添加自定义节点'}</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-500">{editingNode ? '修改节点属性' : '添加数据库、外部服务等非 K8s 资源节点'}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">名称</Label>
|
||||||
|
<Input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })}
|
||||||
|
placeholder="如 MySQL 主库、Redis 集群" className="bg-slate-800/50 border-slate-700 text-slate-200" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">类型</Label>
|
||||||
|
<Select value={editForm.type} onValueChange={(v) => setEditForm({ ...editForm, type: v })}>
|
||||||
|
<SelectTrigger className="bg-slate-800/50 border-slate-700 text-slate-200"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent className="bg-slate-800 border-slate-700">
|
||||||
|
{CUSTOM_NODE_TYPES.map((t) => <SelectItem key={t.value} value={t.value}>{t.label} — {t.desc}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-slate-300">备注</Label>
|
||||||
|
<Textarea value={editForm.note} onChange={(e) => setEditForm({ ...editForm, note: e.target.value })}
|
||||||
|
placeholder="连接信息、用途说明等" rows={2} className="bg-slate-800/50 border-slate-700 text-slate-200 resize-none" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setEditDialogOpen(false)}>取消</Button>
|
||||||
|
<Button onClick={handleSaveNode}>{editingNode ? '保存' : '添加'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import {
|
||||||
|
Network, Server, Globe, Cloud, Boxes, Database, Zap, Box,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { TopologyNode, TopologyEdge } from '@/types';
|
||||||
|
|
||||||
|
// K8s 资源类型 → 拓扑节点类型标签 // Logan.Li
|
||||||
|
export const NODE_TYPE_LABELS: Record<string, string> = {
|
||||||
|
cluster: '集群',
|
||||||
|
loadbalancer: 'Ingress',
|
||||||
|
service: 'Service',
|
||||||
|
app: 'Deployment',
|
||||||
|
server: 'Node',
|
||||||
|
database: '数据库',
|
||||||
|
external: '外部服务',
|
||||||
|
custom: '自定义',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 自定义节点类型选项(编辑模式用)
|
||||||
|
export const CUSTOM_NODE_TYPES = [
|
||||||
|
{ value: 'database', label: '数据库', desc: 'MySQL / Redis / PostgreSQL 等' },
|
||||||
|
{ value: 'external', label: '外部服务', desc: '外部 API / CDN / DNS 等' },
|
||||||
|
{ value: 'custom', label: '自定义', desc: '其他自定义节点' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 判断是否为自定义节点(非 K8s 原生资源)
|
||||||
|
export const isCustomNode = (id: string) => id.startsWith('custom-');
|
||||||
|
|
||||||
|
// 获取节点图标
|
||||||
|
export function getNodeIcon(type: string, className = 'h-5 w-5') {
|
||||||
|
switch (type) {
|
||||||
|
case 'server': return <Server className={className} />;
|
||||||
|
case 'loadbalancer': return <Globe className={className} />;
|
||||||
|
case 'cluster': return <Cloud className={className} />;
|
||||||
|
case 'app': return <Boxes className={className} />;
|
||||||
|
case 'service': return <Network className={className} />;
|
||||||
|
case 'database': return <Database className={className} />;
|
||||||
|
case 'external': return <Zap className={className} />;
|
||||||
|
case 'custom': return <Box className={className} />;
|
||||||
|
default: return <Server className={className} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取节点颜色样式
|
||||||
|
export function getNodeColor(type: string) {
|
||||||
|
switch (type) {
|
||||||
|
case 'server': return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
|
||||||
|
case 'loadbalancer': return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
|
||||||
|
case 'cluster': return 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30';
|
||||||
|
case 'app': return 'bg-rose-500/20 text-rose-400 border-rose-500/30';
|
||||||
|
case 'service': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30';
|
||||||
|
case 'database': return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
|
||||||
|
case 'external': return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
|
||||||
|
case 'custom': return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
||||||
|
default: return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* buildTopology — 从真实 K8s 资源构建分层拓扑图
|
||||||
|
* 层级: Cluster → Ingress → Service → Deployment → Node
|
||||||
|
*/
|
||||||
|
export function buildTopology(
|
||||||
|
cluster: any,
|
||||||
|
ingresses: any[],
|
||||||
|
services: any[],
|
||||||
|
deployments: any[],
|
||||||
|
nodes: any[],
|
||||||
|
): { nodes: TopologyNode[]; edges: TopologyEdge[] } {
|
||||||
|
const tNodes: TopologyNode[] = [];
|
||||||
|
const tEdges: TopologyEdge[] = [];
|
||||||
|
const MAX_PER_LAYER = 12;
|
||||||
|
|
||||||
|
const maxLayerCount = Math.max(1, ingresses.length, services.length, deployments.length, nodes.length);
|
||||||
|
const canvasW = Math.max(900, Math.min(maxLayerCount * 130, 2400));
|
||||||
|
const layerY = [60, 180, 320, 480, 640];
|
||||||
|
|
||||||
|
const distributeX = (count: number): number[] => {
|
||||||
|
const cap = Math.min(count, MAX_PER_LAYER);
|
||||||
|
if (cap === 0) return [];
|
||||||
|
if (cap === 1) return [canvasW / 2];
|
||||||
|
const step = canvasW / (cap + 1);
|
||||||
|
return Array.from({ length: cap }, (_, i) => step * (i + 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
// Layer 0: Cluster
|
||||||
|
const clusterId = `cluster-${cluster.id}`;
|
||||||
|
tNodes.push({
|
||||||
|
id: clusterId, name: cluster.name || '集群', type: 'cluster',
|
||||||
|
x: canvasW / 2, y: layerY[0],
|
||||||
|
data: { kind: 'cluster', version: cluster.version, status: cluster.status, api_server: cluster.api_server, node_count: cluster.node_count, pod_count: cluster.pod_count },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Layer 1: Ingress
|
||||||
|
const ingCap = ingresses.slice(0, MAX_PER_LAYER);
|
||||||
|
const ingXs = distributeX(ingCap.length);
|
||||||
|
ingCap.forEach((ing, i) => {
|
||||||
|
const id = `ingress-${ing.id}`;
|
||||||
|
tNodes.push({ id, name: ing.name, type: 'loadbalancer', x: ingXs[i], y: layerY[1], data: { kind: 'ingress', namespace: ing.namespace, backend: ing.backend, tls: ing.tls, hosts: ing.hosts } });
|
||||||
|
tEdges.push({ id: `e-${clusterId}-${id}`, source: clusterId, target: id, label: 'route' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Layer 2: Service
|
||||||
|
const svcCap = services.slice(0, MAX_PER_LAYER);
|
||||||
|
const svcXs = distributeX(svcCap.length);
|
||||||
|
svcCap.forEach((svc, i) => {
|
||||||
|
const id = `service-${svc.id}`;
|
||||||
|
tNodes.push({ id, name: svc.name, type: 'service', x: svcXs[i], y: layerY[2], data: { kind: 'service', namespace: svc.namespace, type: svc.type, cluster_ip: svc.cluster_ip, external_ip: svc.external_ip, selector: svc.selector } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ingress → Service edges
|
||||||
|
if (ingCap.length > 0 && svcCap.length > 0) {
|
||||||
|
ingCap.forEach((ing) => {
|
||||||
|
const ingNodeId = `ingress-${ing.id}`;
|
||||||
|
const backendSvc = (ing.backend || '').split(':')[0].trim();
|
||||||
|
let matched = svcCap.filter((s) => s.name === backendSvc);
|
||||||
|
if (matched.length === 0) matched = svcCap.filter((s) => s.namespace === ing.namespace);
|
||||||
|
if (matched.length === 0) matched = [svcCap[0]];
|
||||||
|
matched.forEach((svc) => tEdges.push({ id: `e-${ingNodeId}-service-${svc.id}`, source: ingNodeId, target: `service-${svc.id}`, label: '→' }));
|
||||||
|
});
|
||||||
|
} else if (svcCap.length > 0) {
|
||||||
|
svcCap.forEach((svc) => tEdges.push({ id: `e-${clusterId}-service-${svc.id}`, source: clusterId, target: `service-${svc.id}`, label: 'expose' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 3: Deployment
|
||||||
|
const depCap = deployments.slice(0, MAX_PER_LAYER);
|
||||||
|
const depXs = distributeX(depCap.length);
|
||||||
|
depCap.forEach((dep, i) => {
|
||||||
|
const id = `deploy-${dep.id}`;
|
||||||
|
tNodes.push({ id, name: dep.name, type: 'app', x: depXs[i], y: layerY[3], data: { kind: 'deployment', namespace: dep.namespace, replicas: dep.replicas, ready_replicas: dep.ready_replicas, available_replicas: dep.available_replicas, image: dep.image } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Service → Deployment edges
|
||||||
|
if (svcCap.length > 0 && depCap.length > 0) {
|
||||||
|
svcCap.forEach((svc) => {
|
||||||
|
const svcNodeId = `service-${svc.id}`;
|
||||||
|
let matched: any[] = [];
|
||||||
|
try {
|
||||||
|
const selector = typeof svc.selector === 'string' ? JSON.parse(svc.selector || '{}') : (svc.selector || {});
|
||||||
|
const selectorEntries = Object.entries(selector);
|
||||||
|
if (selectorEntries.length > 0) {
|
||||||
|
matched = depCap.filter((d) => {
|
||||||
|
let labels: Record<string, string> = {};
|
||||||
|
try { labels = typeof d.labels === 'string' ? JSON.parse(d.labels || '{}') : (d.labels || {}); } catch { /* */ }
|
||||||
|
return selectorEntries.every(([k, v]) => labels[k] === v);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch { /* */ }
|
||||||
|
if (matched.length === 0) matched = depCap.filter((d) => d.name === svc.name);
|
||||||
|
if (matched.length === 0) matched = depCap.filter((d) => d.namespace === svc.namespace);
|
||||||
|
if (matched.length === 0) matched = [depCap[0]];
|
||||||
|
matched.slice(0, 4).forEach((dep) => tEdges.push({ id: `e-${svcNodeId}-deploy-${dep.id}`, source: svcNodeId, target: `deploy-${dep.id}`, label: 'select' }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Layer 4: Node
|
||||||
|
const nodeCap = nodes.slice(0, MAX_PER_LAYER);
|
||||||
|
const nodeXs = distributeX(nodeCap.length);
|
||||||
|
nodeCap.forEach((n, i) => {
|
||||||
|
const id = `k8snode-${n.id}`;
|
||||||
|
tNodes.push({ id, name: n.name, type: 'server', x: nodeXs[i], y: layerY[4], data: { kind: 'node', ip: n.ip, role: n.role, status: n.status, os: n.os, cpu: n.cpu, memory: n.memory, cpu_usage: n.cpu_usage, memory_usage: n.memory_usage, pod_count: n.pod_count } });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deployment → Node edges
|
||||||
|
const workerNodes = nodeCap.filter((n) => (n.role || '').toLowerCase() === 'worker');
|
||||||
|
const schedulableNodes = workerNodes.length > 0 ? workerNodes : nodeCap;
|
||||||
|
if (depCap.length > 0 && schedulableNodes.length > 0) {
|
||||||
|
depCap.forEach((dep) => {
|
||||||
|
const depNodeId = `deploy-${dep.id}`;
|
||||||
|
schedulableNodes.slice(0, 3).forEach((n) => tEdges.push({ id: `e-${depNodeId}-k8snode-${n.id}`, source: depNodeId, target: `k8snode-${n.id}`, label: 'schedule' }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { nodes: tNodes, edges: tEdges };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 布局数据结构
|
||||||
|
export interface TopologyLayoutData {
|
||||||
|
positions: Record<string, { x: number; y: number }>;
|
||||||
|
customNodes: TopologyNode[];
|
||||||
|
customEdges: TopologyEdge[];
|
||||||
|
}
|
||||||
@@ -162,11 +162,11 @@ export function AlertEventsTab({
|
|||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{getStatusBadge(event.status)}</TableCell>
|
<TableCell>{getStatusBadge(event.status)}</TableCell>
|
||||||
<TableCell>
|
<TableCell className="max-w-[160px]">
|
||||||
<span className="text-sm text-slate-300">{event.target}</span>
|
<span className="text-sm text-slate-300 block truncate" title={event.target}>{event.target}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="text-sm text-slate-300">{event.value}</span>
|
<span className="text-sm text-slate-300">{typeof event.value === 'number' ? event.value.toFixed(1) : event.value}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="text-sm text-slate-500">{event.threshold}</span>
|
<span className="text-sm text-slate-500">{event.threshold}</span>
|
||||||
|
|||||||
@@ -171,8 +171,8 @@ export function AlertRulesTab({
|
|||||||
{rule.source || '--'}
|
{rule.source || '--'}
|
||||||
</code>
|
</code>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="max-w-[180px]">
|
||||||
<code className="text-xs text-blue-300">
|
<code className="text-xs text-blue-300 block truncate" title={rule.expression}>
|
||||||
{rule.expression || '--'}
|
{rule.expression || '--'}
|
||||||
</code>
|
</code>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
|||||||
@@ -579,9 +579,6 @@ const AlertCenter: React.FC = () => {
|
|||||||
checked={rule.enabled}
|
checked={rule.enabled}
|
||||||
onCheckedChange={(checked) => toggleRule(String(rule.id), checked)}
|
onCheckedChange={(checked) => toggleRule(String(rule.id), checked)}
|
||||||
/>
|
/>
|
||||||
<Button variant="ghost" size="sm">
|
|
||||||
<Settings className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
Monitor, Settings, Plus, Edit, Trash2, Copy, Eye, Download, RefreshCw,
|
Monitor, Settings, Plus, Edit, Trash2, Copy, Eye, Download, RefreshCw,
|
||||||
Maximize2, Minimize2, Save, X, ChevronDown, BarChart3, LineChart, PieChart,
|
Maximize2, Minimize2, Save, X, ChevronDown, BarChart3, LineChart, PieChart,
|
||||||
Activity, Cpu, Database, Server, Globe, Zap, AlertTriangle, TrendingUp,
|
Activity, Cpu, Database, Server, Globe, Zap, AlertTriangle, TrendingUp,
|
||||||
TrendingDown, Clock, Calendar, Filter, Search, GripVertical, Move
|
TrendingDown, Clock, Calendar, Filter, Search
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -80,7 +80,7 @@ const GrafanaDashboard: React.FC = () => {
|
|||||||
const [datasources, setDatasources] = useState<Datasource[]>([]);
|
const [datasources, setDatasources] = useState<Datasource[]>([]);
|
||||||
const [activeDashboard, setActiveDashboard] = useState<Dashboard | null>(null);
|
const [activeDashboard, setActiveDashboard] = useState<Dashboard | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [editMode, setEditMode] = useState(false);
|
|
||||||
const [fullscreen, setFullscreen] = useState(false);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [addPanelOpen, setAddPanelOpen] = useState(false);
|
const [addPanelOpen, setAddPanelOpen] = useState(false);
|
||||||
@@ -295,15 +295,6 @@ const GrafanaDashboard: React.FC = () => {
|
|||||||
|
|
||||||
{activeDashboard && (
|
{activeDashboard && (
|
||||||
<>
|
<>
|
||||||
<Button
|
|
||||||
variant={editMode ? 'default' : 'outline'}
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setEditMode(!editMode)}
|
|
||||||
>
|
|
||||||
<Edit className="w-4 h-4 mr-2" />
|
|
||||||
{editMode ? '完成' : '编辑'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button variant="outline" size="sm" onClick={() => setAddPanelOpen(true)}>
|
<Button variant="outline" size="sm" onClick={() => setAddPanelOpen(true)}>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
添加面板
|
添加面板
|
||||||
@@ -349,7 +340,7 @@ const GrafanaDashboard: React.FC = () => {
|
|||||||
{activeDashboard.panels.map((panel) => (
|
{activeDashboard.panels.map((panel) => (
|
||||||
<Card
|
<Card
|
||||||
key={panel.id}
|
key={panel.id}
|
||||||
className={`col-span-${panel.width} ${editMode ? 'ring-2 ring-blue-500 ring-offset-2' : ''}`}
|
className={`col-span-${panel.width}`}
|
||||||
style={{ gridColumn: `span ${panel.width}`, minHeight: `${panel.height * 80}px` }}
|
style={{ gridColumn: `span ${panel.width}`, minHeight: `${panel.height * 80}px` }}
|
||||||
>
|
>
|
||||||
<CardHeader className="py-3 px-4">
|
<CardHeader className="py-3 px-4">
|
||||||
@@ -358,19 +349,7 @@ const GrafanaDashboard: React.FC = () => {
|
|||||||
{getPanelIcon(panel.type)}
|
{getPanelIcon(panel.type)}
|
||||||
{panel.title}
|
{panel.title}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
{editMode && (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
|
||||||
<GripVertical className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
|
|
||||||
<Edit className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-red-500">
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4 pt-0 flex-1">
|
<CardContent className="p-4 pt-0 flex-1">
|
||||||
@@ -579,12 +558,6 @@ const GrafanaDashboard: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{getDatasourceStatusBadge(ds.status)}
|
{getDatasourceStatusBadge(ds.status)}
|
||||||
<Button variant="ghost" size="sm">
|
|
||||||
<Settings className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="sm" className="text-red-500">
|
|
||||||
<Trash2 className="w-4 h-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Shield } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface RoleFormData {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
permission_ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PermissionOption {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RoleFormDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
editingRole: any | null;
|
||||||
|
form: RoleFormData;
|
||||||
|
setForm: React.Dispatch<React.SetStateAction<RoleFormData>>;
|
||||||
|
saving: boolean;
|
||||||
|
onSubmit: () => void;
|
||||||
|
permissions: PermissionOption[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoleFormDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
editingRole,
|
||||||
|
form,
|
||||||
|
setForm,
|
||||||
|
saving,
|
||||||
|
onSubmit,
|
||||||
|
permissions,
|
||||||
|
}: RoleFormDialogProps) {
|
||||||
|
const togglePermission = (id: number, checked: boolean) => {
|
||||||
|
setForm((prev) => {
|
||||||
|
const set = new Set(prev.permission_ids);
|
||||||
|
if (checked) {
|
||||||
|
set.add(id);
|
||||||
|
} else {
|
||||||
|
set.delete(id);
|
||||||
|
}
|
||||||
|
return { ...prev, permission_ids: Array.from(set) };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-800">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5 text-purple-400" />
|
||||||
|
{editingRole ? '编辑角色' : '新建角色'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-500">
|
||||||
|
{editingRole ? '修改角色信息与权限' : '创建新的系统角色并分配权限'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-slate-500">角色名称</Label>
|
||||||
|
<Input
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
|
||||||
|
placeholder="如:运维工程师"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-slate-500">角色编码</Label>
|
||||||
|
<Input
|
||||||
|
value={form.code}
|
||||||
|
onChange={(e) => setForm({ ...form, code: e.target.value })}
|
||||||
|
disabled={!!editingRole}
|
||||||
|
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500 disabled:opacity-60"
|
||||||
|
placeholder="如:ops_engineer"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-slate-500">描述</Label>
|
||||||
|
<Textarea
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500 resize-none"
|
||||||
|
placeholder="角色职责描述(可选)"
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-xs text-slate-500">
|
||||||
|
权限分配
|
||||||
|
{form.permission_ids.length > 0 && (
|
||||||
|
<span className="ml-2 text-slate-400">已选 {form.permission_ids.length} 项</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<div className="max-h-48 overflow-y-auto rounded-lg border border-slate-800 bg-slate-800/30 p-3">
|
||||||
|
{permissions.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500 text-center py-4">暂无可分配权限</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{permissions.map((perm) => (
|
||||||
|
<Label
|
||||||
|
key={perm.id}
|
||||||
|
className="flex items-center gap-2 text-xs text-slate-300 font-normal cursor-pointer hover:text-slate-100"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={form.permission_ids.includes(perm.id)}
|
||||||
|
onCheckedChange={(checked) => togglePermission(perm.id, checked === true)}
|
||||||
|
className="border-slate-600 data-[state=checked]:bg-blue-600 data-[state=checked]:border-blue-600"
|
||||||
|
/>
|
||||||
|
<span className="truncate">{perm.name}</span>
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => onOpenChange(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={onSubmit} disabled={saving}>
|
||||||
|
{saving ? '保存中...' : '保存'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,25 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Plus, Shield, Edit, Eye } from 'lucide-react';
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Plus, Shield, Edit, Eye, Trash2 } from 'lucide-react';
|
||||||
import { userApi } from '@/services/api';
|
import { userApi } from '@/services/api';
|
||||||
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||||
import type { Role } from '@/types';
|
import type { Role } from '@/types';
|
||||||
|
import {
|
||||||
|
RoleFormDialog,
|
||||||
|
type RoleFormData,
|
||||||
|
type PermissionOption,
|
||||||
|
} from './RoleFormDialog';
|
||||||
|
|
||||||
interface SystemRolesTabProps {
|
interface SystemRolesTabProps {
|
||||||
selectedRole: Role | null;
|
selectedRole: Role | null;
|
||||||
@@ -13,45 +28,147 @@ interface SystemRolesTabProps {
|
|||||||
|
|
||||||
// 将后端角色数据规范化为前端 Role 类型 // Logan.Li
|
// 将后端角色数据规范化为前端 Role 类型 // Logan.Li
|
||||||
function normalizeRole(raw: any): Role {
|
function normalizeRole(raw: any): Role {
|
||||||
|
const rawPerms = Array.isArray(raw.permissions) ? raw.permissions : [];
|
||||||
return {
|
return {
|
||||||
id: String(raw.id ?? ''),
|
id: String(raw.id ?? ''),
|
||||||
name: raw.name ?? '',
|
name: raw.name ?? '',
|
||||||
code: raw.code ?? '',
|
code: raw.code ?? '',
|
||||||
description: raw.description ?? '',
|
description: raw.description ?? '',
|
||||||
// 后端可能不返回 permissions/createdAt,给予默认值以避免渲染崩溃
|
// 后端可能返回权限对象数组或字符串数组,统一映射为编码字符串数组
|
||||||
permissions: Array.isArray(raw.permissions) ? raw.permissions : [],
|
permissions: rawPerms.map((p: any) => (typeof p === 'string' ? p : p?.code ?? '')).filter(Boolean),
|
||||||
createdAt: raw.createdAt ?? raw.created_at ?? '',
|
createdAt: raw.createdAt ?? raw.created_at ?? '',
|
||||||
};
|
// 保留原始权限 ID 供编辑使用
|
||||||
|
_permissionIds: Array.isArray(raw.permissions)
|
||||||
|
? raw.permissions.map((p: any) => (typeof p === 'object' ? Number(p.id) : 0)).filter(Boolean)
|
||||||
|
: [],
|
||||||
|
} as Role & { _permissionIds: number[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emptyForm: RoleFormData = {
|
||||||
|
name: '',
|
||||||
|
code: '',
|
||||||
|
description: '',
|
||||||
|
permission_ids: [],
|
||||||
|
};
|
||||||
|
|
||||||
export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTabProps) {
|
export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTabProps) {
|
||||||
|
const confirm = useConfirm();
|
||||||
const [roles, setRoles] = useState<Role[]>([]);
|
const [roles, setRoles] = useState<Role[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [permissions, setPermissions] = useState<PermissionOption[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
// 新建 / 编辑弹窗
|
||||||
let cancelled = false;
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
(async () => {
|
const [editingRole, setEditingRole] = useState<any | null>(null);
|
||||||
|
const [form, setForm] = useState<RoleFormData>({ ...emptyForm });
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const loadRoles = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const resp = await userApi.getRoles();
|
const resp = await userApi.getRoles();
|
||||||
if (cancelled) return;
|
|
||||||
const list = resp?.list ?? [];
|
const list = resp?.list ?? [];
|
||||||
setRoles(list.map(normalizeRole));
|
setRoles(list.map(normalizeRole));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) setRoles([]);
|
setRoles([]);
|
||||||
} finally {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
})();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const loadPermissions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await userApi.getPermissions();
|
||||||
|
const list = resp?.list ?? [];
|
||||||
|
setPermissions(
|
||||||
|
list.map((p: any) => ({
|
||||||
|
id: Number(p.id),
|
||||||
|
name: p.name ?? p.code ?? '',
|
||||||
|
code: p.code ?? '',
|
||||||
|
type: p.type,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
setPermissions([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRoles();
|
||||||
|
loadPermissions();
|
||||||
|
}, [loadRoles, loadPermissions]);
|
||||||
|
|
||||||
|
const openCreate = () => {
|
||||||
|
setEditingRole(null);
|
||||||
|
setForm({ ...emptyForm });
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (role: any) => {
|
||||||
|
setEditingRole(role);
|
||||||
|
setForm({
|
||||||
|
name: role.name ?? '',
|
||||||
|
code: role.code ?? '',
|
||||||
|
description: role.description ?? '',
|
||||||
|
permission_ids: Array.isArray(role._permissionIds) ? [...role._permissionIds] : [],
|
||||||
|
});
|
||||||
|
setDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
toast.error('请输入角色名称');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.code.trim()) {
|
||||||
|
toast.error('请输入角色编码');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
code: form.code.trim(),
|
||||||
|
description: form.description.trim(),
|
||||||
|
permission_ids: form.permission_ids,
|
||||||
|
};
|
||||||
|
if (editingRole) {
|
||||||
|
await userApi.updateRole(editingRole.id, payload);
|
||||||
|
toast.success('角色更新成功');
|
||||||
|
} else {
|
||||||
|
await userApi.createRole(payload);
|
||||||
|
toast.success('角色创建成功');
|
||||||
|
}
|
||||||
|
setDialogOpen(false);
|
||||||
|
await loadRoles();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || '保存失败');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (role: any) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
title: '删除角色',
|
||||||
|
description: `确认删除角色「${role.name}」吗?该操作不可恢复。`,
|
||||||
|
confirmText: '删除',
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await userApi.deleteRole(role.id);
|
||||||
|
toast.success('角色已删除');
|
||||||
|
await loadRoles();
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err?.message || '删除失败');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-end">
|
<div className="flex items-center justify-end">
|
||||||
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
|
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
新建角色
|
新建角色
|
||||||
</Button>
|
</Button>
|
||||||
@@ -90,14 +207,27 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
|||||||
>
|
>
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-slate-400 hover:text-slate-100"
|
||||||
|
onClick={() => openEdit(role)}
|
||||||
|
>
|
||||||
<Edit className="h-4 w-4" />
|
<Edit className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-slate-400 hover:text-red-400"
|
||||||
|
onClick={() => handleDelete(role)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<p className="text-sm text-slate-400 mb-3">{role.description}</p>
|
<p className="text-sm text-slate-400 mb-3">{role.description || '暂无描述'}</p>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{role.permissions.slice(0, 5).map((permission) => (
|
{role.permissions.slice(0, 5).map((permission) => (
|
||||||
<Badge key={permission} variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
<Badge key={permission} variant="outline" className="border-slate-700 text-slate-400 text-xs">
|
||||||
@@ -109,12 +239,83 @@ export function SystemRolesTab({ selectedRole, setSelectedRole }: SystemRolesTab
|
|||||||
+{role.permissions.length - 5}
|
+{role.permissions.length - 5}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{role.permissions.length === 0 && (
|
||||||
|
<span className="text-xs text-slate-600">未分配权限</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 角色详情 Dialog */}
|
||||||
|
<RoleDetailDialog role={selectedRole} onClose={() => setSelectedRole(null)} onEdit={(r) => { setSelectedRole(null); openEdit(r); }} />
|
||||||
|
|
||||||
|
{/* 新建 / 编辑角色 Dialog */}
|
||||||
|
<RoleFormDialog
|
||||||
|
open={dialogOpen}
|
||||||
|
onOpenChange={setDialogOpen}
|
||||||
|
editingRole={editingRole}
|
||||||
|
form={form}
|
||||||
|
setForm={setForm}
|
||||||
|
saving={saving}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
permissions={permissions}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 角色详情查看 Dialog
|
||||||
|
function RoleDetailDialog({
|
||||||
|
role,
|
||||||
|
onClose,
|
||||||
|
onEdit,
|
||||||
|
}: {
|
||||||
|
role: Role | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onEdit: (role: Role) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog open={!!role} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="bg-slate-900 border-slate-800">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-slate-100 flex items-center gap-2">
|
||||||
|
<Shield className="h-5 w-5 text-purple-400" />
|
||||||
|
{role?.name}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-slate-500">编码:{role?.code}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="rounded-lg bg-slate-800/50 p-4">
|
||||||
|
<p className="text-xs text-slate-500 mb-1">描述</p>
|
||||||
|
<p className="text-sm text-slate-200">{role?.description || '暂无描述'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-medium text-slate-300 mb-2">权限列表</h4>
|
||||||
|
{role && role.permissions.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{role.permissions.map((p) => (
|
||||||
|
<Badge key={p} variant="outline" className="border-slate-700 text-slate-400">
|
||||||
|
{p}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-slate-500">未分配权限</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => role && onEdit(role)}>
|
||||||
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,6 +47,20 @@ export const userApi = {
|
|||||||
getRoles: async () => {
|
getRoles: async () => {
|
||||||
return http.get<ListResponse>('/user/roles');
|
return http.get<ListResponse>('/user/roles');
|
||||||
},
|
},
|
||||||
|
// 角色管理(管理员) // Logan.Li
|
||||||
|
createRole: async (data: { name: string; code: string; description?: string; permission_ids?: number[] }) => {
|
||||||
|
return http.post('/admin/roles', data);
|
||||||
|
},
|
||||||
|
updateRole: async (id: number | string, data: { name?: string; code?: string; description?: string; permission_ids?: number[] }) => {
|
||||||
|
return http.put(`/admin/roles/${id}`, data);
|
||||||
|
},
|
||||||
|
deleteRole: async (id: number | string) => {
|
||||||
|
return http.delete(`/admin/roles/${id}`);
|
||||||
|
},
|
||||||
|
// 权限列表(供角色权限选择)
|
||||||
|
getPermissions: async () => {
|
||||||
|
return http.get<ListResponse>('/admin/permissions');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==================== LDAP 配置 API ====================
|
// ==================== LDAP 配置 API ====================
|
||||||
@@ -163,6 +177,12 @@ export const k8sApi = {
|
|||||||
getIngresses: async (clusterId: number | string) => {
|
getIngresses: async (clusterId: number | string) => {
|
||||||
return http.get(`/k8s-clusters/${clusterId}/ingresses`);
|
return http.get(`/k8s-clusters/${clusterId}/ingresses`);
|
||||||
},
|
},
|
||||||
|
getTopology: async (clusterId: number | string) => { // Logan.Li
|
||||||
|
return http.get(`/k8s-clusters/${clusterId}/topology`);
|
||||||
|
},
|
||||||
|
saveTopology: async (clusterId: number | string, layoutData: string) => { // Logan.Li
|
||||||
|
return http.put(`/k8s-clusters/${clusterId}/topology`, { layout_data: layoutData });
|
||||||
|
},
|
||||||
scaleDeployment: async (_clusterId: string, _namespace: string, _name: string, _replicas: number) => {
|
scaleDeployment: async (_clusterId: string, _namespace: string, _name: string, _replicas: number) => {
|
||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -331,7 +331,7 @@ export interface Device {
|
|||||||
export interface TopologyNode {
|
export interface TopologyNode {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
type: 'server' | 'database' | 'cache' | 'loadbalancer' | 'cluster' | 'app' | 'service';
|
type: 'server' | 'database' | 'cache' | 'loadbalancer' | 'cluster' | 'app' | 'service' | 'external' | 'custom';
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
data?: Record<string, unknown>;
|
data?: Record<string, unknown>;
|
||||||
|
|||||||
Reference in new issue
Block a user