Initial commit: LAN Manager with Go backend and Vue frontend
This commit is contained in:
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
lan-manager
|
||||
lan-manager-darwin
|
||||
lan-manager-linux
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
node_modules/
|
||||
|
||||
# Build outputs
|
||||
web/dist/
|
||||
server/static/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Data
|
||||
data/
|
||||
*.db
|
||||
*.db-journal
|
||||
*.log
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
*.env.local
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
24
Makefile
Normal file
24
Makefile
Normal file
@@ -0,0 +1,24 @@
|
||||
.PHONY: all build web server clean run dev
|
||||
|
||||
all: build
|
||||
|
||||
web:
|
||||
cd web && npm install && npm run build
|
||||
rm -rf server/static/*
|
||||
cp -r web/dist/* server/static/
|
||||
|
||||
server:
|
||||
cd server && go build -o ../lan-manager .
|
||||
|
||||
build: web server
|
||||
|
||||
clean:
|
||||
rm -f lan-manager
|
||||
rm -rf server/static/*
|
||||
rm -rf web/dist
|
||||
|
||||
dev:
|
||||
cd web && npm run dev
|
||||
|
||||
run:
|
||||
cd server && go run .
|
||||
136
README.md
Normal file
136
README.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# 局域网机器管理后台 (LAN Manager)
|
||||
|
||||
一个轻量的局域网机器管理后台,用于集中管理多台虚拟机/主机(20 台以内),展示机器信息、服务部署情况及机器之间的转发/依赖关系。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **访客模式**:无需登录,可查看机器在线状态、已同步的系统数据(CPU/内存/磁盘等)、服务列表(脱敏显示)
|
||||
- **管理员模式**:登录后可查看完整信息(含 IP、MAC、拓扑关系),执行增删改、SSH 信息获取、导入导出等操作
|
||||
- **机器管理**:主机名、IP、MAC、操作系统、备注、在线状态自动检测
|
||||
- **服务管理**:为每台机器维护多个服务条目(名称、端口、协议、备注)
|
||||
- **关系管理**:建立机器之间的端口转发、依赖、主从等关系
|
||||
- **拓扑可视化**:基于 AntV G6 的力导向图,支持拖拽、缩放
|
||||
- **SSH 系统信息获取**:支持手动触发,也可配置自动同步;SSH 密码采用 AES-256-GCM 加密存储
|
||||
- **数据导入/导出**:JSON 格式完整备份与恢复
|
||||
- **操作日志**:记录所有增删改操作及来源 IP
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **后端**:Go 1.21+ + Gin + SQLite (modernc.org/sqlite)
|
||||
- **前端**:Vue 3 + Vite + Element Plus + AntV G6
|
||||
- **认证**:Session/Cookie
|
||||
- **SSH**:golang.org/x/crypto/ssh(密码认证)
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
# 1. 安装前端依赖并启动开发服务器
|
||||
cd web
|
||||
npm install
|
||||
npm run dev
|
||||
|
||||
# 2. 启动后端(在项目根目录另开终端)
|
||||
cd server
|
||||
go run .
|
||||
```
|
||||
|
||||
前端开发服务器默认在 `http://localhost:5173`,并代理 `/api` 到 `http://127.0.0.1:8080`。
|
||||
|
||||
### 生产构建(单二进制部署)
|
||||
|
||||
```bash
|
||||
make build
|
||||
# 生成的 lan-manager 可直接运行
|
||||
./lan-manager
|
||||
```
|
||||
|
||||
## Debian 12 一键部署
|
||||
|
||||
```bash
|
||||
# 1. 解压部署包
|
||||
tar -xzf deploy/lan-manager-debian12.tar.gz
|
||||
cd lan-manager-debian12
|
||||
|
||||
# 2. 编辑环境变量(务必修改 ADMIN_PASS 和 ENCRYPT_KEY)
|
||||
nano lan-manager.env
|
||||
|
||||
# 3. 执行安装脚本
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
安装完成后服务自动启动,通过 `systemctl status lan-manager` 查看状态。
|
||||
|
||||
## Nginx 反向代理示例
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name aa.cn;
|
||||
|
||||
ssl_certificate /path/to/aa.cn.crt;
|
||||
ssl_certificate_key /path/to/aa.cn.key;
|
||||
|
||||
client_max_body_size 50M;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
|
||||
| 变量名 | 默认值 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `HOST` | `0.0.0.0` | 监听地址 |
|
||||
| `PORT` | `8080` | 监听端口 |
|
||||
| `DB_PATH` | `./data/lan-manager.db` | SQLite 数据库路径 |
|
||||
| `DATA_DIR` | `./data` | 数据目录 |
|
||||
| `ADMIN_USER` | `admin` | 管理员用户名 |
|
||||
| `ADMIN_PASS` | `admin` | 管理员密码(生产环境务必修改) |
|
||||
| `SESSION_SECRET` | `lan-manager-secret-change-in-production` | Session 密钥 |
|
||||
| `ENCRYPT_KEY` | `lan-manager-default-key-change-in-production` | SSH 密码 AES 加密密钥 |
|
||||
| `PING_INTERVAL` | `60` | Ping 检测间隔(秒) |
|
||||
| `SSH_TIMEOUT` | `10` | SSH 连接超时(秒) |
|
||||
| `LOG_RETENTION_DAYS` | `0` | 日志保留天数(0 表示永久保留) |
|
||||
| `UI_REFRESH_INTERVAL` | `10000` | 前端页面自动刷新间隔(毫秒,0 表示关闭) |
|
||||
| `WEB_STATIC_PATH` | `""` | 外置静态文件目录(空则使用 embed) |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
lan-manager/
|
||||
├── deploy/ # Debian 12 部署包
|
||||
├── docs/ # 需求文档与计划
|
||||
├── scripts/ # systemd 服务示例
|
||||
├── server/ # Go 后端
|
||||
│ ├── main.go
|
||||
│ ├── config/
|
||||
│ ├── db/
|
||||
│ ├── handlers/
|
||||
│ ├── middleware/
|
||||
│ ├── models/
|
||||
│ ├── services/
|
||||
│ └── static/ # 嵌入的前端构建产物
|
||||
├── web/ # Vue 前端
|
||||
│ ├── src/
|
||||
│ └── dist/
|
||||
├── Makefile
|
||||
├── go.mod
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
- **生产环境务必修改**:`ADMIN_PASS`、`SESSION_SECRET`、`ENCRYPT_KEY`
|
||||
- **SSH 密码安全**:数据库中 SSH 密码经 AES-256-GCM 加密存储,API 永不返回密码明文
|
||||
- **自动同步 SSH**:若机器已保存 SSH 用户名和密码,后台每次 Ping 检测成功后会自动通过 SSH 抓取系统信息并同步到数据库
|
||||
- **在线检测机制**:每分钟对所有机器 IP 执行 ICMP Ping,3 秒内无响应视为离线
|
||||
- **磁盘显示策略**:机器列表卡片仅展示 `/` 根分区占用;详情页展示所有真实磁盘分区
|
||||
BIN
deploy/lan-manager-debian12.tar.gz
Normal file
BIN
deploy/lan-manager-debian12.tar.gz
Normal file
Binary file not shown.
175
deploy/lan-manager-debian12/README.md
Normal file
175
deploy/lan-manager-debian12/README.md
Normal file
@@ -0,0 +1,175 @@
|
||||
# LAN Manager Debian 12 部署包
|
||||
|
||||
本包包含 LAN Manager 的 Linux amd64 编译二进制、systemd 服务文件及一键安装脚本。前端资源已静态嵌入二进制中,无需额外安装 Node.js 或 Nginx 即可直接运行。
|
||||
|
||||
## 系统要求
|
||||
|
||||
- Debian 12 (bookworm)
|
||||
- systemd
|
||||
- 不需要安装 SQLite(使用纯 Go 实现的 SQLite 驱动,无需 CGO)
|
||||
|
||||
## 包内文件说明
|
||||
|
||||
```
|
||||
lan-manager # 服务端可执行文件(已嵌入前端)
|
||||
lan-manager.env # 环境变量配置文件
|
||||
lan-manager.service # systemd 服务单元
|
||||
install.sh # 一键安装脚本
|
||||
uninstall.sh # 一键卸载脚本
|
||||
README.md # 本说明文件
|
||||
```
|
||||
|
||||
## 快速安装
|
||||
|
||||
1. 将本包上传到 Debian 12 服务器(例如 `/root/lan-manager-debian12/`)
|
||||
2. 进入目录并执行安装脚本:
|
||||
|
||||
```bash
|
||||
cd lan-manager-debian12
|
||||
sudo bash install.sh
|
||||
```
|
||||
|
||||
安装脚本会完成以下操作:
|
||||
- 创建系统用户 `lanmgr`
|
||||
- 将程序安装到 `/opt/lan-manager/`
|
||||
- 创建数据目录 `/opt/lan-manager/data/`
|
||||
- 注册并启动 systemd 服务 `lan-manager`
|
||||
- 设置开机自启
|
||||
|
||||
## 安装后验证
|
||||
|
||||
```bash
|
||||
# 查看服务状态
|
||||
systemctl status lan-manager
|
||||
|
||||
# 查看实时日志
|
||||
journalctl -u lan-manager -f
|
||||
|
||||
# 测试访问
|
||||
curl http://127.0.0.1:8080/api/auth/me
|
||||
```
|
||||
|
||||
## 配置修改
|
||||
|
||||
所有配置通过 `/opt/lan-manager/lan-manager.env` 中的环境变量管理。常用配置项:
|
||||
|
||||
| 变量 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `HOST` | 监听地址 | `0.0.0.0` |
|
||||
| `PORT` | 监听端口 | `8080` |
|
||||
| `DATA_DIR` | 数据目录 | `/opt/lan-manager/data` |
|
||||
| `DB_PATH` | SQLite 数据库文件路径 | `/opt/lan-manager/data/lan-manager.db` |
|
||||
| `ADMIN_USER` | 管理员用户名 | `admin` |
|
||||
| `ADMIN_PASS` | 管理员密码 | `changeme` |
|
||||
| `SESSION_SECRET` | Session Cookie 密钥 | `lan-manager-secret-change-in-production` |
|
||||
| `PING_INTERVAL` | 自动 Ping/SSH 采集间隔(秒) | `60` |
|
||||
| `UI_REFRESH_INTERVAL` | 前端自动刷新间隔(毫秒) | `10000` |
|
||||
|
||||
修改配置后重启服务生效:
|
||||
|
||||
```bash
|
||||
sudo systemctl restart lan-manager
|
||||
```
|
||||
|
||||
## 防火墙与公网访问
|
||||
|
||||
如果需要通过公网访问,建议开放端口或使用 Nginx 反向代理:
|
||||
|
||||
**方式 A:直接开放端口**
|
||||
```bash
|
||||
sudo ufw allow 8080/tcp
|
||||
```
|
||||
|
||||
**方式 B:Nginx 反向代理(推荐)**
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name your-domain.com;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 安全提醒
|
||||
|
||||
- **首次部署后,请立即修改 `ADMIN_PASS` 和 `SESSION_SECRET`。**
|
||||
- 默认情况下访客可直接查看机器列表,但无法进入机器详情页;后端 API 已对详情接口做了权限拦截。
|
||||
- 建议使用 HTTPS(配合 Nginx + Let's Encrypt/certbot)。
|
||||
|
||||
## 数据备份与恢复(升级前必做)
|
||||
|
||||
系统已内置 JSON 全量导出/导入功能,可完整保留机器、服务、关系数据,以及 SSH 配置信息。
|
||||
|
||||
### 方式 A:Web 界面(管理员登录后)
|
||||
进入 **机器列表** 页面,点击右上角的:
|
||||
- **导出数据**:下载 `lan-manager-backup-YYYYMMdd.json`
|
||||
- **导入数据**:选择之前下载的备份文件,系统会以 **覆盖模式** 恢复全部数据
|
||||
|
||||
### 方式 B:命令行
|
||||
```bash
|
||||
# 导出(需要管理员 Cookie 或 session)
|
||||
curl -b your-session-cookie http://localhost:8080/api/export > lan-manager-backup.json
|
||||
|
||||
# 导入(覆盖模式,会清空现有数据后恢复)
|
||||
curl -b your-session-cookie -X POST http://localhost:8080/api/import \
|
||||
-F "file=@lan-manager-backup.json" \
|
||||
-F "mode=overwrite"
|
||||
```
|
||||
|
||||
### ⚠️ 备份文件安全提醒
|
||||
导出文件中 **包含 SSH 密码**(已按数据库中的密文/明文形式导出)。请妥善保管备份文件,避免泄露。
|
||||
|
||||
## 升级步骤
|
||||
|
||||
1. **先导出数据备份**(见上文)
|
||||
2. 停止服务:
|
||||
```bash
|
||||
sudo systemctl stop lan-manager
|
||||
```
|
||||
3. 替换二进制:
|
||||
```bash
|
||||
sudo cp /path/to/new/lan-manager /opt/lan-manager/
|
||||
sudo chown lanmgr:lanmgr /opt/lan-manager/lan-manager
|
||||
sudo chmod +x /opt/lan-manager/lan-manager
|
||||
```
|
||||
4. 启动服务:
|
||||
```bash
|
||||
sudo systemctl start lan-manager
|
||||
```
|
||||
5. **验证数据完整性**,如有问题可执行导入恢复。
|
||||
|
||||
## 卸载
|
||||
|
||||
如果你想完全卸载 LAN Manager,使用自带的卸载脚本即可:
|
||||
|
||||
```bash
|
||||
cd /root/lan-manager-debian12 # 或你存放安装包的目录
|
||||
sudo bash uninstall.sh
|
||||
```
|
||||
|
||||
卸载脚本会执行以下操作:
|
||||
- 停止并禁用 `lan-manager` systemd 服务
|
||||
- 删除 `/etc/systemd/system/lan-manager.service`
|
||||
- 删除程序及数据目录 `/opt/lan-manager/`
|
||||
- 删除运行用户 `lanmgr`
|
||||
- 提示清理防火墙规则(如有)
|
||||
|
||||
**⚠️ 注意**:卸载会一并删除 `/opt/lan-manager/data/` 下的 SQLite 数据库。如需保留历史数据,请提前备份:
|
||||
```bash
|
||||
cp /opt/lan-manager/data/lan-manager.db /backup/lan-manager.db
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
**Q: 服务启动失败,日志提示端口被占用?**
|
||||
A: 修改 `lan-manager.env` 中的 `PORT` 为其他端口,然后 `systemctl restart lan-manager`。
|
||||
|
||||
**Q: 如何备份数据?**
|
||||
A: 直接复制 SQLite 数据库文件即可:`cp /opt/lan-manager/data/lan-manager.db /backup/lan-manager.db`。
|
||||
73
deploy/lan-manager-debian12/install.sh
Normal file
73
deploy/lan-manager-debian12/install.sh
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
INSTALL_DIR="/opt/lan-manager"
|
||||
SERVICE_NAME="lan-manager"
|
||||
USER_NAME="lanmgr"
|
||||
|
||||
echo "=== LAN Manager Debian 12 安装脚本 ==="
|
||||
|
||||
# 1. 检查 root 权限
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "请使用 root 权限运行:sudo bash install.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 2. 创建用户(如果不存在)
|
||||
if ! id "$USER_NAME" &>/dev/null; then
|
||||
echo "[1/7] 创建运行用户 $USER_NAME ..."
|
||||
useradd --system --no-create-home --shell /usr/sbin/nologin "$USER_NAME"
|
||||
else
|
||||
echo "[1/7] 用户 $USER_NAME 已存在,跳过"
|
||||
fi
|
||||
|
||||
# 3. 停止服务(如果正在运行)以便覆盖二进制
|
||||
echo "[2/7] 停止现有服务(如果正在运行)..."
|
||||
if systemctl is-active --quiet "$SERVICE_NAME" 2>/dev/null; then
|
||||
systemctl stop "$SERVICE_NAME"
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
# 4. 复制程序文件
|
||||
echo "[3/7] 复制程序文件到 $INSTALL_DIR ..."
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
cp lan-manager "$INSTALL_DIR/"
|
||||
cp lan-manager.env "$INSTALL_DIR/"
|
||||
|
||||
# 5. 创建数据目录并授权
|
||||
echo "[4/7] 创建数据目录 ..."
|
||||
mkdir -p "$INSTALL_DIR/data"
|
||||
chown -R "$USER_NAME:$USER_NAME" "$INSTALL_DIR"
|
||||
chmod +x "$INSTALL_DIR/lan-manager"
|
||||
|
||||
# 6. 安装 systemd 服务
|
||||
echo "[5/7] 安装 systemd 服务 ..."
|
||||
cp lan-manager.service /etc/systemd/system/
|
||||
chmod 644 /etc/systemd/system/lan-manager.service
|
||||
systemctl daemon-reload
|
||||
|
||||
# 7. 启动并设置开机自启
|
||||
echo "[6/7] 启动服务并设置开机自启 ..."
|
||||
systemctl enable --now lan-manager
|
||||
|
||||
# 8. 检查状态
|
||||
echo "[7/7] 检查服务状态 ..."
|
||||
sleep 2
|
||||
if systemctl is-active --quiet lan-manager; then
|
||||
echo "✅ 服务已正常启动"
|
||||
else
|
||||
echo "❌ 服务启动失败,请查看日志:journalctl -u lan-manager -n 50"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 安装完成 ==="
|
||||
echo "访问地址: http://$(hostname -I | awk '{print $1}'):8080"
|
||||
echo ""
|
||||
echo "⚠️ 重要提示:"
|
||||
echo " 1. 默认管理员账号: admin / changeme"
|
||||
echo " 2. 请务必修改 $INSTALL_DIR/lan-manager.env 中的 ADMIN_PASS 和 SESSION_SECRET"
|
||||
echo " 3. 修改配置后执行: systemctl restart lan-manager"
|
||||
echo " 4. 查看日志: journalctl -u lan-manager -f"
|
||||
echo " 5. 如需开放防火墙端口: ufw allow 8080/tcp"
|
||||
echo ""
|
||||
31
deploy/lan-manager-debian12/lan-manager.env
Normal file
31
deploy/lan-manager-debian12/lan-manager.env
Normal file
@@ -0,0 +1,31 @@
|
||||
# LAN Manager Environment Configuration
|
||||
# 服务监听地址与端口
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
|
||||
# 数据目录与数据库路径(建议放在 /opt/lan-manager/data)
|
||||
DATA_DIR=/opt/lan-manager/data
|
||||
DB_PATH=/opt/lan-manager/data/lan-manager.db
|
||||
|
||||
# 后台自动 Ping/SSH 同步间隔(秒)
|
||||
PING_INTERVAL=60
|
||||
SSH_TIMEOUT=10
|
||||
|
||||
# 管理员账号(首次登录后务必修改)
|
||||
ADMIN_USER=admin
|
||||
ADMIN_PASS=changeme
|
||||
|
||||
# Session 密钥(生产环境务必修改,越长越安全)
|
||||
SESSION_SECRET=lan-manager-secret-change-in-production
|
||||
|
||||
# 日志保留天数(0 表示永久保留)
|
||||
LOG_RETENTION_DAYS=30
|
||||
|
||||
# 前端自动刷新间隔(毫秒,0 表示关闭)
|
||||
UI_REFRESH_INTERVAL=10000
|
||||
|
||||
# 密码加密密钥(生产环境务必修改,任意长度字符串即可)
|
||||
ENCRYPT_KEY=lan-manager-secret-change-in-production
|
||||
|
||||
# 可选:外部静态文件路径(默认使用嵌入的前端,无需修改)
|
||||
# WEB_STATIC_PATH=/opt/lan-manager/static
|
||||
16
deploy/lan-manager-debian12/lan-manager.service
Normal file
16
deploy/lan-manager-debian12/lan-manager.service
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=LAN Manager
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=lanmgr
|
||||
Group=lanmgr
|
||||
WorkingDirectory=/opt/lan-manager
|
||||
ExecStart=/opt/lan-manager/lan-manager
|
||||
EnvironmentFile=/opt/lan-manager/lan-manager.env
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
58
deploy/lan-manager-debian12/uninstall.sh
Normal file
58
deploy/lan-manager-debian12/uninstall.sh
Normal file
@@ -0,0 +1,58 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
INSTALL_DIR="/opt/lan-manager"
|
||||
SERVICE_NAME="lan-manager"
|
||||
USER_NAME="lanmgr"
|
||||
|
||||
echo "=== LAN Manager 卸载脚本 ==="
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo "请使用 root 权限运行:sudo bash uninstall.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. 停止并禁用服务
|
||||
if systemctl list-units --type=service --all | grep -q "$SERVICE_NAME"; then
|
||||
echo "[1/5] 停止并禁用 systemd 服务 ..."
|
||||
systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
||||
systemctl disable "$SERVICE_NAME" 2>/dev/null || true
|
||||
else
|
||||
echo "[1/5] 服务不存在,跳过"
|
||||
fi
|
||||
|
||||
# 2. 删除 systemd 服务文件
|
||||
if [ -f "/etc/systemd/system/$SERVICE_NAME.service" ]; then
|
||||
echo "[2/5] 删除 systemd 服务文件 ..."
|
||||
rm -f "/etc/systemd/system/$SERVICE_NAME.service"
|
||||
systemctl daemon-reload
|
||||
else
|
||||
echo "[2/5] systemd 服务文件不存在,跳过"
|
||||
fi
|
||||
|
||||
# 3. 删除程序和数据目录
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
echo "[3/5] 删除程序目录 $INSTALL_DIR ..."
|
||||
rm -rf "$INSTALL_DIR"
|
||||
else
|
||||
echo "[3/5] 程序目录不存在,跳过"
|
||||
fi
|
||||
|
||||
# 4. 删除用户和组
|
||||
if id "$USER_NAME" &>/dev/null; then
|
||||
echo "[4/5] 删除运行用户 $USER_NAME ..."
|
||||
userdel "$USER_NAME" 2>/dev/null || true
|
||||
else
|
||||
echo "[4/5] 用户 $USER_NAME 不存在,跳过"
|
||||
fi
|
||||
|
||||
# 5. 清理防火墙规则(可选)
|
||||
echo "[5/5] 检查防火墙规则 ..."
|
||||
if command -v ufw &>/dev/null && ufw status | grep -q "8080/tcp"; then
|
||||
echo " 发现 ufw 规则 8080/tcp,建议手动删除:ufw delete allow 8080/tcp"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== 卸载完成 ==="
|
||||
echo "如需保留数据库备份,请确认已备份 /opt/lan-manager/data/lan-manager.db"
|
||||
echo ""
|
||||
67
docs/plan.md
Normal file
67
docs/plan.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 局域网机器管理后台 — 实现计划
|
||||
|
||||
## 技术选型
|
||||
|
||||
| 层 | 技术 |
|
||||
|---|---|
|
||||
| 后端 | Go + Gin |
|
||||
| 前端 | Vue 3 + Vite + Element Plus + AntV G6 |
|
||||
| 数据库 | SQLite (modernc.org/sqlite,纯 Go,无需 CGO) |
|
||||
| 认证 | 预设用户名密码 + Session/Cookie |
|
||||
| SSH | golang.org/x/crypto/ssh(仅密码认证) |
|
||||
| Ping | `golang.org/x/net/icmp` + 系统 `/bin/ping` 命令回退 |
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
lan-manager/
|
||||
├── server/
|
||||
│ ├── main.go # 入口
|
||||
│ ├── config/ # 配置管理(支持配置文件 + 环境变量)
|
||||
│ ├── models/ # 数据模型
|
||||
│ ├── db/ # 数据库 + migration
|
||||
│ ├── handlers/ # HTTP handlers
|
||||
│ ├── services/ # 业务逻辑 (auth, ping, ssh, import/export)
|
||||
│ └── middleware/ # CORS, 认证, 日志
|
||||
├── web/ # Vue 前端
|
||||
│ └── src/
|
||||
├── scripts/
|
||||
│ └── lan-manager.service # Systemd 服务示例
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── Makefile
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### Phase 1: 后端基础
|
||||
1. 初始化 Go 项目 + 依赖(Gin, modernc.org/sqlite, crypto/ssh)
|
||||
2. 配置管理:支持配置文件 + 环境变量(HOST/PORT/DB_PATH/ADMIN_USER/ADMIN_PASS 等)
|
||||
3. SQLite 数据库 + 表创建(含 machines 扩展字段 cpu_info/memory_info/disk_info/uptime/listen_ports/ssh_synced_at)
|
||||
4. 数据模型定义
|
||||
5. 基础 CRUD API (machines, services, relationships)
|
||||
6. 认证模块:登录/登出/Session 中间件,区分访客和管理员权限
|
||||
|
||||
### Phase 2: 核心业务
|
||||
7. Ping 定时检测服务(1 分钟间隔),Linux 上优先使用系统 ping 命令,避免权限问题
|
||||
8. SSH 系统信息获取(仅密码认证,不保存凭据,单次有效),结果支持同步到机器记录
|
||||
9. 操作日志中间件(记录增删改,含用户名)
|
||||
10. 导入/导出 API(JSON 格式)
|
||||
11. 健康检查接口 `/api/health`
|
||||
|
||||
### Phase 3: 前端
|
||||
12. Vue 3 项目初始化 + Element Plus + 路由(Vue Router)
|
||||
13. 登录页面
|
||||
14. 机器列表页(访客:脱敏显示 IP/MAC/备注/关系)
|
||||
15. 机器详情页(访客:隐藏敏感字段和 SSH 入口;管理员:完整功能)
|
||||
16. 拓扑图页(AntV G6,仅管理员可访问)
|
||||
17. 操作日志页(仅管理员)
|
||||
18. 导入/导出功能(仅管理员)
|
||||
|
||||
### Phase 4: 整合与部署
|
||||
19. Go embed 打包前端静态文件 + 支持外置 `WEB_STATIC_PATH`
|
||||
20. 编写 Makefile(含 Linux amd64 交叉编译)
|
||||
21. 编写 Systemd 服务示例脚本
|
||||
22. 测试 + 修复
|
||||
23. README 编写(含 Linux 部署说明)
|
||||
385
docs/requirements.md
Normal file
385
docs/requirements.md
Normal file
@@ -0,0 +1,385 @@
|
||||
# 局域网机器管理后台 — 需求文档
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
搭建一个本地局域网机器管理后台,用于集中管理多台虚拟机/主机(20 台以内),直观展示机器信息、服务部署情况及机器之间的转发/依赖关系,解决"机器多、IP 多、服务分散、容易遗忘"的痛点。
|
||||
|
||||
系统分为**访客模式**和**管理员模式**:
|
||||
- **访客**:无需登录,可查看机器的在线状态、已同步的系统数据(CPU/内存/磁盘等)、服务列表,但无法查看 IP 地址、MAC 地址、拓扑关系,也无法执行任何管理操作。
|
||||
- **管理员**:登录后可查看完整信息(含 IP、MAC、拓扑关系),执行增删改、SSH 信息获取、导入导出等所有操作。
|
||||
|
||||
---
|
||||
|
||||
## 二、核心功能模块
|
||||
|
||||
### 1. 用户认证
|
||||
|
||||
- **管理员账号**:预设一组用户名/密码,通过环境变量或配置文件初始化
|
||||
- **认证方式**:Session/Cookie 或简单 Token
|
||||
- **未登录用户**:自动降级为访客模式,仅开放只读脱敏接口
|
||||
|
||||
### 2. 机器管理(增删改查)
|
||||
|
||||
| 字段 | 说明 | 是否必填 | 访客可见 |
|
||||
|------|------|---------|---------|
|
||||
| 主机名/别名 | 用户自定义的易记名称,如 `web-server-01` | 是 | ✅ 是 |
|
||||
| IP 地址 | 机器在局域网中的固定 IP,如 `192.168.1.100` | 是 | ❌ 否(仅管理员) |
|
||||
| MAC 地址 | 可选,用于辅助识别 | 否 | ❌ 否(仅管理员) |
|
||||
| 操作系统 | Linux / Windows / macOS(可下拉选择 + 自定义) | 是 | ✅ 是 |
|
||||
| 系统版本 | 如 `Ubuntu 22.04`、`Windows 11`、`macOS 14` | 否 | ✅ 是 |
|
||||
| 备注/描述 | 自由文本,记录用途、负责人等信息 | 否 | ❌ 否(仅管理员) |
|
||||
| 状态 | 在线 / 离线(自动检测) | 自动 | ✅ 是 |
|
||||
| SSH 系统信息 | CPU / 内存 / 磁盘 / 运行时间等(手动触发获取,可同步到机器记录) | 按需 | ✅ 是(已同步的数据) |
|
||||
| 创建时间 / 更新时间 | 系统自动记录 | 自动 | ❌ 否(仅管理员) |
|
||||
|
||||
### 3. 服务管理
|
||||
|
||||
- 每台机器可以关联**多个服务条目**
|
||||
- 服务字段:
|
||||
- **服务名称**(如 `nginx`、`frps`、`docker`、`mysql`、`redis` 等)
|
||||
- **端口号**(如 `80`、`443`、`8080`)
|
||||
- **协议**(TCP / UDP / HTTP / HTTPS)
|
||||
- **备注**(自由描述,如 "反向代理到 192.168.1.50:8080")
|
||||
- 服务类型支持**预设常用服务** + **自定义服务**
|
||||
- **访客可见性**:访客可以看到服务名称、端口、协议,但**不可查看备注**(备注可能包含敏感 IP 或内部信息)
|
||||
|
||||
### 4. 机器关系管理
|
||||
|
||||
- 机器之间可以建立**连接关系**,用于表达端口转发、代理、依赖等
|
||||
- 关系字段:
|
||||
- **源机器** → **目标机器**
|
||||
- **关系类型**(预设 + 自定义):
|
||||
- `端口转发`(如 Nginx 反代、FRP 穿透)
|
||||
- `依赖`(如 A 机器上的应用依赖 B 机器的数据库)
|
||||
- `主从`(如主从复制、集群节点)
|
||||
- `自定义`
|
||||
- **端口信息**:源端口 → 目标端口(如 `8080 → 80`)
|
||||
- **备注**:自由描述
|
||||
- 关系应支持**双向可视化**(A→B 建立关系后,两边都能看到)
|
||||
- **访客可见性**:❌ **完全不可见**,拓扑图和关系列表仅对管理员开放
|
||||
|
||||
### 5. 在线状态检测
|
||||
|
||||
- 定时对每台机器执行 **ICMP Ping** 检测
|
||||
- 检测间隔:**1 分钟**
|
||||
- 界面用颜色/图标直观展示在线状态:🟢 在线 / 🔴 离线
|
||||
- **访客可见性**:✅ **可见**
|
||||
|
||||
### 6. SSH 系统信息获取(安全敏感)
|
||||
|
||||
- **不保存任何 SSH 凭据**(用户名、密码、密钥均不持久化)
|
||||
- 仅支持 **密码认证**,不支持密钥认证
|
||||
- **仅管理员可见和操作**:在机器详情页提供单独的按钮 **"获取系统信息"**
|
||||
- 点击后弹窗要求输入 SSH **用户名**和**密码**
|
||||
- 连接成功后获取以下信息并展示:
|
||||
- 主机名、操作系统版本(精确)
|
||||
- CPU 使用率 / 核心数
|
||||
内存使用率 / 总量
|
||||
- 磁盘使用率 / 各分区
|
||||
- 运行时间(uptime)
|
||||
- 监听中的端口列表
|
||||
- **单次有效**:凭据仅在本次请求中使用,用完即销毁,不写入任何存储
|
||||
- 获取到的信息可选择性地**同步到机器记录中**(如系统版本、CPU、内存、磁盘),同步后访客即可查看这些已缓存的数据
|
||||
- SSH 连接超时时间:10 秒
|
||||
|
||||
### 7. 拓扑/关系图可视化
|
||||
|
||||
- 以**节点-连线图**的形式展示所有机器及其关系
|
||||
- 节点颜色区分操作系统类型
|
||||
- 连线标注关系类型和端口号
|
||||
- 支持拖拽、缩放、点击节点查看详情
|
||||
- 使用 G6 或类似的图可视化库
|
||||
- **访客可见性**:❌ **完全不可见**,仅管理员可访问拓扑图页面
|
||||
|
||||
### 8. 数据导入/导出
|
||||
|
||||
- 支持 **JSON 格式**完整导出(机器 + 服务 + 关系)
|
||||
- 支持 **JSON 格式**导入(覆盖或追加模式)
|
||||
- 导出文件名带时间戳,如 `lan-manager-backup-20260414.json`
|
||||
- **管理员专属功能**,访客无权限
|
||||
|
||||
### 9. 操作日志
|
||||
|
||||
- 记录所有增删改操作:
|
||||
- 操作时间
|
||||
- 操作类型(创建 / 修改 / 删除)
|
||||
- 操作对象(机器 / 服务 / 关系)
|
||||
- 变更内容(旧值 → 新值)
|
||||
- 来源 IP(请求的 IP)
|
||||
- 操作用户(管理员用户名)
|
||||
- 日志只增不改,可在独立页面查看
|
||||
- 支持按时间/操作类型筛选
|
||||
- **管理员专属功能**
|
||||
|
||||
---
|
||||
|
||||
## 三、UI 界面设计
|
||||
|
||||
### 页面 1:机器列表(主页,访客/管理员共用)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 🔍 搜索 IP / 主机名 [Linux▼] │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ ┌─────┐ ┌─────────────┐ ┌───────────┐ ┌──────────┐ ┌────────┐ │
|
||||
│ │状态 │ │ 主机名 │ │ IP* │ │ 系统 │ │ 服务数 │ │
|
||||
│ ├─────┤ ├─────────────┤ ├───────────┤ ├──────────┤ ├────────┤ │
|
||||
│ │ 🟢 │ │ nginx-proxy │ 192.168.*.* │ Linux │ 2 │ │
|
||||
│ │ 🟢 │ │ db-master │ 192.168.*.* │ Linux │ 1 │ │
|
||||
│ │ 🔴 │ │ dev-win │ 192.168.*.* │ Windows │ 3 │ │
|
||||
│ │ 🟢 │ │ mac-build │ 192.168.*.* │ macOS │ 0 │ │
|
||||
│ └─────┘ └─────────────┘ └───────────┘ └──────────┘ └────────┘ │
|
||||
│ │
|
||||
│ 底部导航: [机器列表] [拓扑图*] [操作日志*] [导入/导出*] [登录] │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> 注:带 `*` 的功能/数据仅管理员可见,访客访问时隐藏或显示为占位符(如 IP 显示为 `***`)。
|
||||
|
||||
- 支持按系统类型筛选、按 IP / 主机名搜索(访客搜索时只能匹配主机名,IP 对访客不可见)
|
||||
- 点击某行进入**机器详情**
|
||||
|
||||
### 页面 2:机器详情(访客/管理员共用,但字段可见性不同)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ ← 返回 nginx-proxy │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ 基本信息 [编辑*] │
|
||||
│ IP: 192.168.1.1 (访客显示: ***.***.*.*) │
|
||||
│ MAC: AA:BB:CC:DD:EE:FF (访客不可见) │
|
||||
│ 系统: Linux (Ubuntu 22.04) │
|
||||
│ 状态: 🟢 在线 │
|
||||
│ 备注: 主网关入口 (访客不可见) │
|
||||
│ │
|
||||
│ SSH 系统信息(管理员触发后展示) │
|
||||
│ CPU: 12% (4 cores) │
|
||||
│ 内存: 2.1G / 8G (26%) │
|
||||
│ 磁盘: / 45G / 100G (45%), /data 120G / 500G (24%) │
|
||||
│ 运行时间: 15 days, 3:22 │
|
||||
│ 监听端口: 22, 80, 443 │
|
||||
│ [获取系统信息*] [同步到记录*] │
|
||||
│ │
|
||||
│ 服务列表 [+ 添加服务*] │
|
||||
│ ┌─────────┬──────┬──────┬──────────────────────┐ │
|
||||
│ │ 名称 │ 端口 │ 协议 │ 备注* │ │
|
||||
│ ├─────────┼──────┼──────┼──────────────────────┤ │
|
||||
│ │ nginx │ 80 │ TCP │ 反代入口 │ │
|
||||
│ │ nginx │ 443 │ TCP │ HTTPS │ │
|
||||
│ └─────────┴──────┴──────┴──────────────────────┘ │
|
||||
│ │
|
||||
│ 关联关系 [+ 添加关系*] │
|
||||
│ → db-master :80 → :3306 [端口转发] [编辑*] [删除*] │
|
||||
│ → frps :8080 → :8080 [依赖] [编辑*] [删除*] │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
> 注:带 `*` 的按钮/字段仅对管理员显示;关联关系区域对访客完全隐藏。
|
||||
|
||||
### 页面 3:拓扑图
|
||||
|
||||
- 力导向图(force-directed graph),节点可拖拽
|
||||
- 节点颜色:Linux 蓝色、Windows 绿色、macOS 紫色
|
||||
- 连线样式:
|
||||
- 端口转发:实线 + 端口标注
|
||||
- 依赖:虚线 + 文字标注
|
||||
- 主从:带箭头实线
|
||||
- 点击节点高亮关联连线,其余淡化
|
||||
- **仅管理员可访问**,访客访问时重定向到机器列表或提示登录
|
||||
|
||||
### 页面 4:操作日志
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ [全部▼] [2026-04-14] [导出日志*] │
|
||||
├──────────────────────────────────────────────────────────────────┤
|
||||
│ 2026-04-14 15:30:22 admin 修改 机器 nginx-proxy │
|
||||
│ 备注: "" → "主网关入口" │
|
||||
│ 2026-04-14 15:28:01 admin 创建 服务 nginx on nginx-proxy │
|
||||
│ 2026-04-14 14:55:10 admin 删除 关系 db-master → cache-01 │
|
||||
│ 2026-04-14 14:30:00 admin 创建 机器 cache-01 │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **仅管理员可访问**
|
||||
|
||||
---
|
||||
|
||||
## 四、技术选型
|
||||
|
||||
| 层 | 技术 | 说明 |
|
||||
|---|---|---|
|
||||
| 后端 | **Go 1.21+** | 单二进制部署,内置 net/http 或 Gin 框架,SSH 客户端用 golang.org/x/crypto/ssh |
|
||||
| 前端 | **Vue 3 + Vite** | 轻量,配合 Element Plus / Naive UI 组件库 |
|
||||
| 拓扑图 | **AntV G6** | 专门的图可视化库,力导向图 + 自定义节点样式 |
|
||||
| 存储 | **SQLite** | 单文件数据库,适合 20 台以内数据量 |
|
||||
| 在线检测 | Go `net` 包 ICMP Ping / 系统 ping 命令回退 | 定时 goroutine,1 分钟间隔 |
|
||||
| SSH | `golang.org/x/crypto/ssh` | 纯 Go 实现,不依赖系统 ssh 命令,仅密码认证 |
|
||||
| 认证 | Session/Cookie | 简单用户名密码,预设单管理员账号 |
|
||||
|
||||
### 项目结构
|
||||
|
||||
```
|
||||
lan-manager/
|
||||
├── server/
|
||||
│ ├── main.go # 入口,HTTP 服务启动
|
||||
│ ├── config/ # 配置管理
|
||||
│ ├── db/ # SQLite 数据库连接 + migration
|
||||
│ ├── models/ # 数据模型 (Machine, Service, Relationship, Log, User)
|
||||
│ ├── handlers/ # HTTP 请求处理
|
||||
│ ├── services/ # 业务逻辑 (ping, ssh, import/export, auth)
|
||||
│ └── middleware/ # CORS, 认证中间件, 日志中间件
|
||||
├── web/
|
||||
│ ├── src/
|
||||
│ │ ├── views/ # 页面组件 (MachineList, MachineDetail, Topology, Logs, Login)
|
||||
│ │ ├── components/ # 复用组件 (StatusBadge, ServiceTable, RelationEditor)
|
||||
│ │ ├── api/ # 前端 API 封装
|
||||
│ │ └── App.vue
|
||||
│ └── index.html
|
||||
├── scripts/
|
||||
│ └── lan-manager.service # Systemd 服务示例
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── Makefile # 构建脚本
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、数据存储设计
|
||||
|
||||
```sql
|
||||
machines 表:
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
hostname TEXT NOT NULL -- 主机名/别名
|
||||
ip TEXT NOT NULL UNIQUE -- IP 地址
|
||||
mac TEXT -- MAC 地址
|
||||
os_type TEXT NOT NULL -- Linux / Windows / macOS / Other
|
||||
os_version TEXT -- 系统版本
|
||||
notes TEXT -- 备注
|
||||
is_online INTEGER DEFAULT 0 -- 0=离线, 1=在线
|
||||
last_ping_at DATETIME -- 最后一次 ping 时间
|
||||
-- SSH 系统信息字段(管理员获取后可选择同步到这里,供访客查看)
|
||||
cpu_info TEXT -- CPU 信息 JSON
|
||||
memory_info TEXT -- 内存信息 JSON
|
||||
disk_info TEXT -- 磁盘信息 JSON
|
||||
uptime TEXT -- 运行时间
|
||||
listen_ports TEXT -- 监听端口列表,逗号分隔
|
||||
ssh_synced_at DATETIME -- 最后一次同步 SSH 数据的时间
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
|
||||
services 表:
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE
|
||||
name TEXT NOT NULL -- 服务名称
|
||||
port INTEGER NOT NULL -- 端口号
|
||||
protocol TEXT DEFAULT 'TCP' -- TCP / UDP / HTTP / HTTPS
|
||||
notes TEXT -- 备注
|
||||
|
||||
relationships 表:
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
source_machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE
|
||||
target_machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE
|
||||
relation_type TEXT NOT NULL -- port_forward / dependency / primary_secondary / custom
|
||||
source_port INTEGER -- 源端口
|
||||
target_port INTEGER -- 目标端口
|
||||
notes TEXT -- 备注
|
||||
|
||||
operation_logs 表:
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT
|
||||
action TEXT NOT NULL -- create / update / delete
|
||||
entity_type TEXT NOT NULL -- machine / service / relationship
|
||||
entity_id INTEGER -- 操作对象的 ID
|
||||
entity_name TEXT -- 操作对象的名称描述
|
||||
old_value TEXT -- 旧值 (JSON)
|
||||
new_value TEXT -- 新值 (JSON)
|
||||
source_ip TEXT -- 请求来源 IP
|
||||
username TEXT -- 操作用户名(admin 或匿名)
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、API 设计
|
||||
|
||||
### 认证
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|------|------|------|
|
||||
| POST | `/api/auth/login` | 管理员登录(用户名/密码) |
|
||||
| POST | `/api/auth/logout` | 退出登录 |
|
||||
| GET | `/api/auth/me` | 获取当前登录状态 |
|
||||
|
||||
### 机器
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/machines` | 获取机器列表(支持 ?os_type=Linux 筛选) | 访客/管理员 |
|
||||
| GET | `/api/machines/:id` | 获取单个机器详情(含服务,管理员额外含关系、IP、MAC、备注) | 访客/管理员 |
|
||||
| POST | `/api/machines` | 创建机器 | 管理员 |
|
||||
| PUT | `/api/machines/:id` | 更新机器 | 管理员 |
|
||||
| DELETE | `/api/machines/:id` | 删除机器 | 管理员 |
|
||||
| POST | `/api/machines/:id/ssh-info` | SSH 获取系统信息(需传用户名密码,单次有效) | 管理员 |
|
||||
| POST | `/api/machines/:id/sync-ssh` | 将最近一次 SSH 获取的信息同步到机器记录 | 管理员 |
|
||||
|
||||
### 服务
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/machines/:machine_id/services` | 获取某机器的服务列表 | 访客/管理员 |
|
||||
| POST | `/api/machines/:machine_id/services` | 添加服务 | 管理员 |
|
||||
| PUT | `/api/services/:id` | 更新服务 | 管理员 |
|
||||
| DELETE | `/api/services/:id` | 删除服务 | 管理员 |
|
||||
|
||||
### 关系
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/relationships` | 获取全部关系 | 管理员 |
|
||||
| POST | `/api/relationships` | 创建关系 | 管理员 |
|
||||
| PUT | `/api/relationships/:id` | 更新关系 | 管理员 |
|
||||
| DELETE | `/api/relationships/:id` | 删除关系 | 管理员 |
|
||||
|
||||
### 日志 / 导入导出
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/logs` | 获取操作日志 | 管理员 |
|
||||
| GET | `/api/export` | 导出数据(JSON 下载) | 管理员 |
|
||||
| POST | `/api/import` | 导入数据(上传 JSON) | 管理员 |
|
||||
|
||||
### 其他
|
||||
|
||||
| 方法 | 路径 | 说明 | 权限 |
|
||||
|------|------|------|------|
|
||||
| GET | `/api/health` | 服务健康检查 | 公开 |
|
||||
|
||||
---
|
||||
|
||||
## 七、已确认的设计决策
|
||||
|
||||
| 项目 | 决定 |
|
||||
|------|------|
|
||||
| 部署方式 | Web 应用,源码部署,Linux Systemd 服务 |
|
||||
| Ping 间隔 | 1 分钟 |
|
||||
| SSH 系统信息 | 支持,**不保存凭据**,仅密码认证,手动输入用户名密码,单次有效,独立按钮触发,仅管理员可用 |
|
||||
| SSH 密钥 | **不支持** |
|
||||
| 自动发现机器 | 不需要,纯手动添加 |
|
||||
| 用户认证 | **需要**,预设单管理员账号,访客免认证只读访问脱敏数据 |
|
||||
| 拓扑图 | 保留,力导向图,**仅管理员可见** |
|
||||
| 导入/导出 | 需要,JSON 格式,管理员专属 |
|
||||
| 操作日志 | 需要,记录所有增删改 |
|
||||
| 机器规模 | 20 台以内 |
|
||||
| 前端技术 | Vue 3 + 组件库 + AntV G6 |
|
||||
| 后端技术 | Go + SQLite |
|
||||
| 数据自动备份 | **不需要** |
|
||||
| 前端构建方式 | 支持 embed 打包 + 外置静态目录两种模式 |
|
||||
|
||||
---
|
||||
|
||||
## 八、待确认细节
|
||||
|
||||
1. **前端 UI 组件库**:Element Plus 还是 Naive UI?(或者你有其他偏好)
|
||||
2. **前端构建方式**:把 Vue 打包后的静态文件嵌入 Go 二进制(`embed.FS`,单文件部署),还是前后端分别部署?(建议同时支持两种)
|
||||
3. **操作日志保留策略**:永久保留还是定期清理(如只保留最近 90 天)?
|
||||
48
go.mod
Normal file
48
go.mod
Normal file
@@ -0,0 +1,48 @@
|
||||
module lan-manager
|
||||
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gin-contrib/sessions v1.1.0 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/context v1.1.2 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/gorilla/sessions v1.4.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.22.0 // indirect
|
||||
golang.org/x/crypto v0.50.0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.48.2 // indirect
|
||||
)
|
||||
103
go.sum
Normal file
103
go.sum
Normal file
@@ -0,0 +1,103 @@
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sessions v1.1.0 h1:00mhHfNEGF5sP2fwxa98aRqj1FOJdL6IkR86n2hOiBo=
|
||||
github.com/gin-contrib/sessions v1.1.0/go.mod h1:TyYZDIs6qCQg2SOoYPgMT9pAkmZceVNEJMcv5qbIy60=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/context v1.1.2 h1:WRkNAv2uoa03QNIc1A6u4O7DAGMUVoopZhkiXWA2V1o=
|
||||
github.com/gorilla/context v1.1.2/go.mod h1:KDPwT9i/MeWHiLl90fuTgrt4/wPcv75vFAZLaOOcbxM=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver v1.17.3 h1:TQyXhnsWfWtgAhMtOgtYHMTkZIfBTpMTsMnd9ZBeHxQ=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
|
||||
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
23
scripts/lan-manager.service
Normal file
23
scripts/lan-manager.service
Normal file
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=LAN Manager
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
ExecStart=/opt/lan-manager/lan-manager
|
||||
WorkingDirectory=/opt/lan-manager
|
||||
Restart=on-failure
|
||||
Environment="HOST=0.0.0.0"
|
||||
Environment="PORT=8080"
|
||||
Environment="DB_PATH=/opt/lan-manager/data/lan-manager.db"
|
||||
Environment="DATA_DIR=/opt/lan-manager/data"
|
||||
Environment="ADMIN_USER=admin"
|
||||
Environment="ADMIN_PASS=changeme"
|
||||
Environment="SESSION_SECRET=your-random-secret-here"
|
||||
Environment="PING_INTERVAL=60"
|
||||
Environment="SSH_TIMEOUT=10"
|
||||
Environment="LOG_RETENTION_DAYS=90"
|
||||
Environment="UI_REFRESH_INTERVAL=10000"
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
67
server/config/config.go
Normal file
67
server/config/config.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Host string
|
||||
Port string
|
||||
DBPath string
|
||||
DataDir string
|
||||
PingInterval int // seconds
|
||||
SSHTimeout int // seconds
|
||||
LogLevel string
|
||||
WebStaticPath string
|
||||
AdminUser string
|
||||
AdminPass string
|
||||
SessionSecret string
|
||||
LogRetentionDays int // 0 = keep forever
|
||||
UIRefreshInterval int // milliseconds, 0 = disable auto refresh
|
||||
EncryptKey string // for AES password encryption
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
cfg := &Config{
|
||||
Host: getEnv("HOST", "0.0.0.0"),
|
||||
Port: getEnv("PORT", "8080"),
|
||||
DBPath: getEnv("DB_PATH", "./data/lan-manager.db"),
|
||||
DataDir: getEnv("DATA_DIR", "./data"),
|
||||
PingInterval: getEnvInt("PING_INTERVAL", 60),
|
||||
SSHTimeout: getEnvInt("SSH_TIMEOUT", 10),
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
WebStaticPath: getEnv("WEB_STATIC_PATH", ""),
|
||||
AdminUser: getEnv("ADMIN_USER", "admin"),
|
||||
AdminPass: getEnv("ADMIN_PASS", "admin"),
|
||||
SessionSecret: getEnv("SESSION_SECRET", "lan-manager-secret-change-in-production"),
|
||||
LogRetentionDays: getEnvInt("LOG_RETENTION_DAYS", 0),
|
||||
UIRefreshInterval: getEnvInt("UI_REFRESH_INTERVAL", 10000),
|
||||
EncryptKey: getEnv("ENCRYPT_KEY", ""),
|
||||
}
|
||||
|
||||
if cfg.AdminPass == "admin" {
|
||||
log.Println("[WARN] Using default admin password. Please set ADMIN_PASS env variable in production.")
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func getEnvInt(key string, defaultValue int) int {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return defaultValue
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return n
|
||||
}
|
||||
107
server/db/db.go
Normal file
107
server/db/db.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"lan-manager/server/config"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var DB *sql.DB
|
||||
|
||||
func Init(cfg *config.Config) error {
|
||||
if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0750); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
DB, err = sql.Open("sqlite", cfg.DBPath+"?_pragma=foreign_keys(1)")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := DB.Ping(); err != nil {
|
||||
return err
|
||||
}
|
||||
DB.SetMaxOpenConns(1)
|
||||
return migrate()
|
||||
}
|
||||
|
||||
func migrate() error {
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS machines (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hostname TEXT NOT NULL,
|
||||
ip TEXT NOT NULL UNIQUE,
|
||||
mac TEXT,
|
||||
os_type TEXT NOT NULL,
|
||||
os_version TEXT,
|
||||
notes TEXT,
|
||||
ssh_port INTEGER DEFAULT 22,
|
||||
is_online INTEGER DEFAULT 0,
|
||||
last_ping_at DATETIME,
|
||||
cpu_info TEXT,
|
||||
memory_info TEXT,
|
||||
disk_info TEXT,
|
||||
uptime TEXT,
|
||||
listen_ports TEXT,
|
||||
ssh_synced_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`ALTER TABLE machines ADD COLUMN ssh_port INTEGER DEFAULT 22`,
|
||||
`ALTER TABLE machines ADD COLUMN ssh_username TEXT`,
|
||||
`ALTER TABLE machines ADD COLUMN ssh_password TEXT`,
|
||||
`CREATE TABLE IF NOT EXISTS services (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
protocol TEXT DEFAULT 'TCP',
|
||||
notes TEXT,
|
||||
target_machine_id INTEGER REFERENCES machines(id) ON DELETE SET NULL,
|
||||
target_notes TEXT
|
||||
)`,
|
||||
`ALTER TABLE services ADD COLUMN target_machine_id INTEGER REFERENCES machines(id) ON DELETE SET NULL`,
|
||||
`ALTER TABLE services ADD COLUMN target_notes TEXT`,
|
||||
`CREATE TABLE IF NOT EXISTS relationships (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source_machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE,
|
||||
target_machine_id INTEGER NOT NULL REFERENCES machines(id) ON DELETE CASCADE,
|
||||
relation_type TEXT NOT NULL,
|
||||
source_port INTEGER,
|
||||
target_port INTEGER,
|
||||
notes TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS operation_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id INTEGER,
|
||||
entity_name TEXT,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
source_ip TEXT,
|
||||
username TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_machines_ip ON machines(ip)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_services_machine ON services(machine_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_rel_src ON relationships(source_machine_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_rel_tgt ON relationships(target_machine_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_logs_created ON operation_logs(created_at)`,
|
||||
}
|
||||
for _, s := range stmts {
|
||||
if _, err := DB.Exec(s); err != nil {
|
||||
// ignore "duplicate column" errors for ALTER TABLE compatibility
|
||||
if strings.Contains(err.Error(), "duplicate column name") {
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
55
server/handlers/auth.go
Normal file
55
server/handlers/auth.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
"lan-manager/server/config"
|
||||
"lan-manager/server/middleware"
|
||||
"lan-manager/server/models"
|
||||
)
|
||||
|
||||
type AuthHandler struct {
|
||||
Cfg *config.Config
|
||||
}
|
||||
|
||||
func NewAuthHandler(cfg *config.Config) *AuthHandler {
|
||||
return &AuthHandler{Cfg: cfg}
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req models.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if req.Username != h.Cfg.AdminUser || req.Password != h.Cfg.AdminPass {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
|
||||
return
|
||||
}
|
||||
session := sessions.Default(c)
|
||||
session.Set(middleware.AdminSessionKey, req.Username)
|
||||
if err := session.Save(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"username": req.Username, "is_admin": true})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
session.Delete(middleware.AdminSessionKey)
|
||||
_ = session.Save()
|
||||
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
|
||||
}
|
||||
|
||||
func (h *AuthHandler) Me(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
user := session.Get(middleware.AdminSessionKey)
|
||||
if user == nil {
|
||||
c.JSON(http.StatusOK, gin.H{"is_admin": false, "ui_refresh_interval": h.Cfg.UIRefreshInterval})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"is_admin": true, "username": user.(string), "ui_refresh_interval": h.Cfg.UIRefreshInterval})
|
||||
}
|
||||
127
server/handlers/export.go
Normal file
127
server/handlers/export.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"lan-manager/server/db"
|
||||
"lan-manager/server/middleware"
|
||||
"lan-manager/server/models"
|
||||
)
|
||||
|
||||
type ExportHandler struct{}
|
||||
|
||||
func NewExportHandler() *ExportHandler {
|
||||
return &ExportHandler{}
|
||||
}
|
||||
|
||||
func (h *ExportHandler) Export(c *gin.Context) {
|
||||
data := map[string]interface{}{}
|
||||
|
||||
machines := []models.Machine{}
|
||||
if rows, err := db.DB.Query(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines`); err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var m models.Machine
|
||||
var lp, ss *time.Time
|
||||
if err := rows.Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.CreatedAt, &m.UpdatedAt); err == nil {
|
||||
m.LastPingAt = lp
|
||||
m.SSHSyncedAt = ss
|
||||
machines = append(machines, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
data["machines"] = machines
|
||||
|
||||
services := []models.Service{}
|
||||
if rows, err := db.DB.Query(`SELECT id, machine_id, name, port, protocol, notes, target_machine_id, target_notes FROM services`); err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var s models.Service
|
||||
var tm sql.NullInt64
|
||||
if err := rows.Scan(&s.ID, &s.MachineID, &s.Name, &s.Port, &s.Protocol, &s.Notes, &tm, &s.TargetNotes); err == nil {
|
||||
if tm.Valid {
|
||||
tmid := tm.Int64
|
||||
s.TargetMachineID = &tmid
|
||||
}
|
||||
services = append(services, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
data["services"] = services
|
||||
|
||||
relationships := []models.Relationship{}
|
||||
if rows, err := db.DB.Query(`SELECT id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes FROM relationships`); err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var r models.Relationship
|
||||
if err := rows.Scan(&r.ID, &r.SourceMachineID, &r.TargetMachineID, &r.RelationType, &r.SourcePort, &r.TargetPort, &r.Notes); err == nil {
|
||||
relationships = append(relationships, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
data["relationships"] = relationships
|
||||
|
||||
b, _ := json.MarshalIndent(data, "", " ")
|
||||
filename := "lan-manager-backup-" + time.Now().Format("20060102") + ".json"
|
||||
c.Header("Content-Disposition", "attachment; filename="+filename)
|
||||
c.Data(http.StatusOK, "application/json", b)
|
||||
}
|
||||
|
||||
func (h *ExportHandler) Import(c *gin.Context) {
|
||||
file, _, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var data map[string]json.RawMessage
|
||||
if err := json.NewDecoder(file).Decode(&data); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
mode := c.DefaultPostForm("mode", "append")
|
||||
if mode == "overwrite" {
|
||||
_, _ = db.DB.Exec(`DELETE FROM relationships`)
|
||||
_, _ = db.DB.Exec(`DELETE FROM services`)
|
||||
_, _ = db.DB.Exec(`DELETE FROM machines`)
|
||||
}
|
||||
|
||||
if raw, ok := data["machines"]; ok {
|
||||
var list []models.Machine
|
||||
if err := json.Unmarshal(raw, &list); err == nil {
|
||||
for _, m := range list {
|
||||
_, _ = db.DB.Exec(`INSERT OR IGNORE INTO machines (id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, cpu_info, memory_info, disk_info, uptime, listen_ports) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
m.ID, m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, m.SSHPassword, 0, m.CPUInfo, m.MemoryInfo, m.DiskInfo, m.Uptime, m.ListenPorts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if raw, ok := data["services"]; ok {
|
||||
var list []models.Service
|
||||
if err := json.Unmarshal(raw, &list); err == nil {
|
||||
for _, s := range list {
|
||||
_, _ = db.DB.Exec(`INSERT OR IGNORE INTO services (id, machine_id, name, port, protocol, notes, target_machine_id, target_notes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
s.ID, s.MachineID, s.Name, s.Port, s.Protocol, s.Notes, s.TargetMachineID, s.TargetNotes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if raw, ok := data["relationships"]; ok {
|
||||
var list []models.Relationship
|
||||
if err := json.Unmarshal(raw, &list); err == nil {
|
||||
for _, r := range list {
|
||||
_, _ = db.DB.Exec(`INSERT OR IGNORE INTO relationships (id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
r.ID, r.SourceMachineID, r.TargetMachineID, r.RelationType, r.SourcePort, r.TargetPort, r.Notes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
middleware.LogOperation("import", "system", nil, "json-import", "", "", c.ClientIP(), middleware.CurrentUser(c))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "imported"})
|
||||
}
|
||||
35
server/handlers/health.go
Normal file
35
server/handlers/health.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"lan-manager/server/db"
|
||||
)
|
||||
|
||||
var startTime = time.Now()
|
||||
|
||||
type HealthHandler struct{}
|
||||
|
||||
func NewHealthHandler() *HealthHandler {
|
||||
return &HealthHandler{}
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Check(c *gin.Context) {
|
||||
dbErr := db.DB.Ping()
|
||||
status := "ok"
|
||||
dbStatus := "ok"
|
||||
if dbErr != nil {
|
||||
status = "error"
|
||||
dbStatus = "error"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": status,
|
||||
"db": dbStatus,
|
||||
"version": "1.0.0",
|
||||
"uptime": time.Since(startTime).String(),
|
||||
"go": runtime.Version(),
|
||||
})
|
||||
}
|
||||
51
server/handlers/logs.go
Normal file
51
server/handlers/logs.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"lan-manager/server/db"
|
||||
"lan-manager/server/models"
|
||||
)
|
||||
|
||||
type LogHandler struct{}
|
||||
|
||||
func NewLogHandler() *LogHandler {
|
||||
return &LogHandler{}
|
||||
}
|
||||
|
||||
func (h *LogHandler) List(c *gin.Context) {
|
||||
action := c.Query("action")
|
||||
date := c.Query("date")
|
||||
query := `SELECT id, action, entity_type, entity_id, entity_name, old_value, new_value, source_ip, username, created_at FROM operation_logs WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
if action != "" {
|
||||
query += ` AND action = ?`
|
||||
args = append(args, action)
|
||||
}
|
||||
if date != "" {
|
||||
query += ` AND DATE(created_at) = ?`
|
||||
args = append(args, date)
|
||||
}
|
||||
query += ` ORDER BY created_at DESC LIMIT 1000`
|
||||
|
||||
rows, err := db.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
logs := []models.OperationLog{}
|
||||
for rows.Next() {
|
||||
var l models.OperationLog
|
||||
var eid sql.NullInt64
|
||||
if err := rows.Scan(&l.ID, &l.Action, &l.EntityType, &eid, &l.EntityName, &l.OldValue, &l.NewValue, &l.SourceIP, &l.Username, &l.CreatedAt); err == nil {
|
||||
if eid.Valid {
|
||||
l.EntityID = &eid.Int64
|
||||
}
|
||||
logs = append(logs, l)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, logs)
|
||||
}
|
||||
335
server/handlers/machines.go
Normal file
335
server/handlers/machines.go
Normal file
@@ -0,0 +1,335 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"lan-manager/server/db"
|
||||
"lan-manager/server/middleware"
|
||||
"lan-manager/server/models"
|
||||
"lan-manager/server/services"
|
||||
"lan-manager/server/utils"
|
||||
)
|
||||
|
||||
type MachineHandler struct{}
|
||||
|
||||
func NewMachineHandler() *MachineHandler {
|
||||
return &MachineHandler{}
|
||||
}
|
||||
|
||||
// sanitizeMachine removes sensitive fields for guest users
|
||||
func sanitizeMachine(m *models.Machine) {
|
||||
m.IP = ""
|
||||
m.MAC = models.NullString{}
|
||||
m.Notes = models.NullString{}
|
||||
m.SSHUsername = models.NullString{}
|
||||
m.SSHPassword = models.NullString{}
|
||||
m.ListenPorts = models.NullString{}
|
||||
m.CreatedAt = time.Time{}
|
||||
m.UpdatedAt = time.Time{}
|
||||
}
|
||||
|
||||
func (h *MachineHandler) List(c *gin.Context) {
|
||||
isAdmin := middleware.IsAdmin(c)
|
||||
osFilter := c.Query("os_type")
|
||||
search := c.Query("search")
|
||||
|
||||
query := `SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
if osFilter != "" {
|
||||
query += ` AND os_type = ?`
|
||||
args = append(args, osFilter)
|
||||
}
|
||||
if search != "" {
|
||||
if isAdmin {
|
||||
query += ` AND (hostname LIKE ? OR ip LIKE ?)`
|
||||
args = append(args, "%"+search+"%", "%"+search+"%")
|
||||
} else {
|
||||
query += ` AND hostname LIKE ?`
|
||||
args = append(args, "%"+search+"%")
|
||||
}
|
||||
}
|
||||
query += ` ORDER BY created_at DESC`
|
||||
|
||||
rows, err := db.DB.Query(query, args...)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
machines := []models.Machine{}
|
||||
for rows.Next() {
|
||||
var m models.Machine
|
||||
var lp, ss *time.Time
|
||||
err := rows.Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.CreatedAt, &m.UpdatedAt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
m.LastPingAt = lp
|
||||
m.SSHSyncedAt = ss
|
||||
m.SSHPassword = models.NullString{} // never return password
|
||||
if !isAdmin {
|
||||
sanitizeMachine(&m)
|
||||
}
|
||||
machines = append(machines, m)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
// Fetch service counts
|
||||
if len(machines) > 0 {
|
||||
scRows, err := db.DB.Query(`SELECT machine_id, COUNT(*) FROM services GROUP BY machine_id`)
|
||||
if err == nil {
|
||||
defer scRows.Close()
|
||||
for scRows.Next() {
|
||||
var mid int64
|
||||
var cnt int
|
||||
if err := scRows.Scan(&mid, &cnt); err == nil {
|
||||
for i := range machines {
|
||||
if machines[i].ID == mid {
|
||||
machines[i].ServiceCount = cnt
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, machines)
|
||||
}
|
||||
|
||||
func (h *MachineHandler) Get(c *gin.Context) {
|
||||
isAdmin := middleware.IsAdmin(c)
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var m models.Machine
|
||||
var lp, ss *time.Time
|
||||
err = db.DB.QueryRow(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines WHERE id = ?`, id).
|
||||
Scan(&m.ID, &m.Hostname, &m.IP, &m.MAC, &m.OsType, &m.OsVersion, &m.Notes, &m.SSHPort, &m.SSHUsername, &m.SSHPassword, &m.IsOnline, &lp, &m.CPUInfo, &m.MemoryInfo, &m.DiskInfo, &m.Uptime, &m.ListenPorts, &ss, &m.CreatedAt, &m.UpdatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
m.LastPingAt = lp
|
||||
m.SSHSyncedAt = ss
|
||||
|
||||
services := []models.Service{}
|
||||
if rows, err := db.DB.Query(`SELECT id, machine_id, name, port, protocol, notes FROM services WHERE machine_id = ?`, id); err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var s models.Service
|
||||
if err := rows.Scan(&s.ID, &s.MachineID, &s.Name, &s.Port, &s.Protocol, &s.Notes); err == nil {
|
||||
if !isAdmin {
|
||||
s.Notes = ""
|
||||
}
|
||||
services = append(services, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
relationships := []models.Relationship{}
|
||||
if isAdmin {
|
||||
if rows, err := db.DB.Query(`SELECT id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes FROM relationships WHERE source_machine_id = ? OR target_machine_id = ?`, id, id); err == nil {
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var r models.Relationship
|
||||
if err := rows.Scan(&r.ID, &r.SourceMachineID, &r.TargetMachineID, &r.RelationType, &r.SourcePort, &r.TargetPort, &r.Notes); err == nil {
|
||||
relationships = append(relationships, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m.SSHPassword = models.NullString{} // never return password
|
||||
if !isAdmin {
|
||||
sanitizeMachine(&m)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"machine": m,
|
||||
"services": services,
|
||||
"relationships": relationships,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *MachineHandler) Create(c *gin.Context) {
|
||||
var m models.Machine
|
||||
if err := c.ShouldBindJSON(&m); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
encPass, _ := utils.Encrypt(m.SSHPassword.String)
|
||||
passField := models.NullString{}
|
||||
passField.String = encPass
|
||||
passField.Valid = encPass != ""
|
||||
res, err := db.DB.Exec(`INSERT INTO machines (hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passField)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
m.ID = id
|
||||
m.CreatedAt = time.Now()
|
||||
m.UpdatedAt = time.Now()
|
||||
|
||||
middleware.LogOperation("create", "machine", &m.ID, m.Hostname, "", middleware.ToJSON(m), c.ClientIP(), middleware.CurrentUser(c))
|
||||
c.JSON(http.StatusCreated, m)
|
||||
}
|
||||
|
||||
func (h *MachineHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var m models.Machine
|
||||
if err := c.ShouldBindJSON(&m); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
var old models.Machine
|
||||
var oldPass string
|
||||
var lp, ss *time.Time
|
||||
_ = db.DB.QueryRow(`SELECT id, hostname, ip, mac, os_type, os_version, notes, ssh_port, ssh_username, ssh_password, is_online, last_ping_at, cpu_info, memory_info, disk_info, uptime, listen_ports, ssh_synced_at, created_at, updated_at FROM machines WHERE id = ?`, id).
|
||||
Scan(&old.ID, &old.Hostname, &old.IP, &old.MAC, &old.OsType, &old.OsVersion, &old.Notes, &old.SSHPort, &old.SSHUsername, &oldPass, &old.IsOnline, &lp, &old.CPUInfo, &old.MemoryInfo, &old.DiskInfo, &old.Uptime, &old.ListenPorts, &ss, &old.CreatedAt, &old.UpdatedAt)
|
||||
old.LastPingAt = lp
|
||||
old.SSHSyncedAt = ss
|
||||
|
||||
// Keep old password if new one is empty
|
||||
passToSave := oldPass
|
||||
if m.SSHPassword.String != "" {
|
||||
encPass, err := utils.Encrypt(m.SSHPassword.String)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "encrypt failed"})
|
||||
return
|
||||
}
|
||||
passToSave = encPass
|
||||
}
|
||||
|
||||
_, err = db.DB.Exec(`UPDATE machines SET hostname=?, ip=?, mac=?, os_type=?, os_version=?, notes=?, ssh_port=?, ssh_username=?, ssh_password=?, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
|
||||
m.Hostname, m.IP, m.MAC, m.OsType, m.OsVersion, m.Notes, m.SSHPort, m.SSHUsername, passToSave, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
m.ID = id
|
||||
old.SSHPassword = models.NullString{}
|
||||
m.SSHPassword = models.NullString{}
|
||||
middleware.LogOperation("update", "machine", &m.ID, m.Hostname, middleware.ToJSON(old), middleware.ToJSON(m), c.ClientIP(), middleware.CurrentUser(c))
|
||||
c.JSON(http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (h *MachineHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var name string
|
||||
_ = db.DB.QueryRow(`SELECT hostname FROM machines WHERE id = ?`, id).Scan(&name)
|
||||
_, err = db.DB.Exec(`DELETE FROM machines WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
middleware.LogOperation("delete", "machine", &id, name, "", "", c.ClientIP(), middleware.CurrentUser(c))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
func (h *MachineHandler) SSHInfo(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var req models.SSHInfoRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var ip string
|
||||
var sshPort int
|
||||
var savedUser, savedPass string
|
||||
if err := db.DB.QueryRow(`SELECT ip, ssh_port, ssh_username, ssh_password FROM machines WHERE id = ?`, id).Scan(&ip, &sshPort, &savedUser, &savedPass); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "machine not found"})
|
||||
return
|
||||
}
|
||||
|
||||
user := req.Username
|
||||
pass := req.Password
|
||||
|
||||
// 如果前端没传密码,尝试用数据库保存的凭据
|
||||
if pass == "" {
|
||||
if savedUser == "" || savedPass == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未保存 SSH 凭据,请手动输入"})
|
||||
return
|
||||
}
|
||||
plainPass, decErr := utils.Decrypt(savedPass)
|
||||
if decErr != nil || plainPass == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未保存 SSH 凭据,请手动输入"})
|
||||
return
|
||||
}
|
||||
user = savedUser
|
||||
pass = plainPass
|
||||
}
|
||||
|
||||
result, err := services.GetSSHInfo(ip, sshPort, user, pass)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Save credentials to machine record for auto-sync (only when user explicitly provided password)
|
||||
if req.Password != "" {
|
||||
encPass, _ := utils.Encrypt(req.Password)
|
||||
_, _ = db.DB.Exec(`UPDATE machines SET ssh_username=?, ssh_password=? WHERE id=?`, req.Username, encPass, id)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func (h *MachineHandler) SyncSSH(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var req models.SSHInfoResult
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
portsStr := ""
|
||||
if len(req.ListenPorts) > 0 {
|
||||
b, _ := json.Marshal(req.ListenPorts)
|
||||
portsStr = string(b)
|
||||
// remove brackets
|
||||
if len(portsStr) > 2 {
|
||||
portsStr = portsStr[1 : len(portsStr)-1]
|
||||
}
|
||||
}
|
||||
_, err = db.DB.Exec(`UPDATE machines SET cpu_info=?, memory_info=?, disk_info=?, uptime=?, listen_ports=?, ssh_synced_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
|
||||
req.RawCPUInfo, req.RawMemoryInfo, req.RawDiskInfo, req.Uptime, portsStr, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
middleware.LogOperation("update", "machine", &id, "sync-ssh", "", "", c.ClientIP(), middleware.CurrentUser(c))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "synced"})
|
||||
}
|
||||
94
server/handlers/relationships.go
Normal file
94
server/handlers/relationships.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"lan-manager/server/db"
|
||||
"lan-manager/server/middleware"
|
||||
"lan-manager/server/models"
|
||||
)
|
||||
|
||||
type RelationshipHandler struct{}
|
||||
|
||||
func NewRelationshipHandler() *RelationshipHandler {
|
||||
return &RelationshipHandler{}
|
||||
}
|
||||
|
||||
func (h *RelationshipHandler) List(c *gin.Context) {
|
||||
rows, err := db.DB.Query(`SELECT id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes FROM relationships`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
list := []models.Relationship{}
|
||||
for rows.Next() {
|
||||
var r models.Relationship
|
||||
if err := rows.Scan(&r.ID, &r.SourceMachineID, &r.TargetMachineID, &r.RelationType, &r.SourcePort, &r.TargetPort, &r.Notes); err == nil {
|
||||
list = append(list, r)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (h *RelationshipHandler) Create(c *gin.Context) {
|
||||
var r models.Relationship
|
||||
if err := c.ShouldBindJSON(&r); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
res, err := db.DB.Exec(`INSERT INTO relationships (source_machine_id, target_machine_id, relation_type, source_port, target_port, notes) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
r.SourceMachineID, r.TargetMachineID, r.RelationType, r.SourcePort, r.TargetPort, r.Notes)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
r.ID = id
|
||||
middleware.LogOperation("create", "relationship", &r.ID, strconv.FormatInt(r.SourceMachineID, 10)+" -> "+strconv.FormatInt(r.TargetMachineID, 10), "", middleware.ToJSON(r), c.ClientIP(), middleware.CurrentUser(c))
|
||||
c.JSON(http.StatusCreated, r)
|
||||
}
|
||||
|
||||
func (h *RelationshipHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var r models.Relationship
|
||||
if err := c.ShouldBindJSON(&r); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var old models.Relationship
|
||||
_ = db.DB.QueryRow(`SELECT id, source_machine_id, target_machine_id, relation_type, source_port, target_port, notes FROM relationships WHERE id = ?`, id).
|
||||
Scan(&old.ID, &old.SourceMachineID, &old.TargetMachineID, &old.RelationType, &old.SourcePort, &old.TargetPort, &old.Notes)
|
||||
_, err = db.DB.Exec(`UPDATE relationships SET source_machine_id=?, target_machine_id=?, relation_type=?, source_port=?, target_port=?, notes=? WHERE id=?`,
|
||||
r.SourceMachineID, r.TargetMachineID, r.RelationType, r.SourcePort, r.TargetPort, r.Notes, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
r.ID = id
|
||||
middleware.LogOperation("update", "relationship", &r.ID, strconv.FormatInt(r.SourceMachineID, 10)+" -> "+strconv.FormatInt(r.TargetMachineID, 10), middleware.ToJSON(old), middleware.ToJSON(r), c.ClientIP(), middleware.CurrentUser(c))
|
||||
c.JSON(http.StatusOK, r)
|
||||
}
|
||||
|
||||
func (h *RelationshipHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var src, tgt int64
|
||||
_ = db.DB.QueryRow(`SELECT source_machine_id, target_machine_id FROM relationships WHERE id = ?`, id).Scan(&src, &tgt)
|
||||
_, err = db.DB.Exec(`DELETE FROM relationships WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
middleware.LogOperation("delete", "relationship", &id, strconv.FormatInt(src, 10)+" -> "+strconv.FormatInt(tgt, 10), "", "", c.ClientIP(), middleware.CurrentUser(c))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
139
server/handlers/services.go
Normal file
139
server/handlers/services.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"lan-manager/server/db"
|
||||
"lan-manager/server/middleware"
|
||||
"lan-manager/server/models"
|
||||
)
|
||||
|
||||
type ServiceHandler struct{}
|
||||
|
||||
func NewServiceHandler() *ServiceHandler {
|
||||
return &ServiceHandler{}
|
||||
}
|
||||
|
||||
func (h *ServiceHandler) Create(c *gin.Context) {
|
||||
machineID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid machine_id"})
|
||||
return
|
||||
}
|
||||
var s models.Service
|
||||
if err := c.ShouldBindJSON(&s); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.MachineID = machineID
|
||||
res, err := db.DB.Exec(`INSERT INTO services (machine_id, name, port, protocol, notes, target_machine_id, target_notes) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
s.MachineID, s.Name, s.Port, s.Protocol, s.Notes, s.TargetMachineID, s.TargetNotes)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
s.ID = id
|
||||
middleware.LogOperation("create", "service", &s.ID, s.Name+" on machine "+strconv.FormatInt(machineID, 10), "", middleware.ToJSON(s), c.ClientIP(), middleware.CurrentUser(c))
|
||||
c.JSON(http.StatusCreated, s)
|
||||
}
|
||||
|
||||
func (h *ServiceHandler) Update(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var s models.Service
|
||||
if err := c.ShouldBindJSON(&s); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
var old models.Service
|
||||
_ = db.DB.QueryRow(`SELECT id, machine_id, name, port, protocol, notes, target_machine_id, target_notes FROM services WHERE id = ?`, id).
|
||||
Scan(&old.ID, &old.MachineID, &old.Name, &old.Port, &old.Protocol, &old.Notes, &old.TargetMachineID, &old.TargetNotes)
|
||||
_, err = db.DB.Exec(`UPDATE services SET name=?, port=?, protocol=?, notes=?, target_machine_id=?, target_notes=? WHERE id=?`,
|
||||
s.Name, s.Port, s.Protocol, s.Notes, s.TargetMachineID, s.TargetNotes, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
s.ID = id
|
||||
middleware.LogOperation("update", "service", &s.ID, s.Name, middleware.ToJSON(old), middleware.ToJSON(s), c.ClientIP(), middleware.CurrentUser(c))
|
||||
c.JSON(http.StatusOK, s)
|
||||
}
|
||||
|
||||
func (h *ServiceHandler) Delete(c *gin.Context) {
|
||||
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var name string
|
||||
var machineID int64
|
||||
_ = db.DB.QueryRow(`SELECT name, machine_id FROM services WHERE id = ?`, id).Scan(&name, &machineID)
|
||||
_, err = db.DB.Exec(`DELETE FROM services WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
middleware.LogOperation("delete", "service", &id, name+" on machine "+strconv.FormatInt(machineID, 10), "", "", c.ClientIP(), middleware.CurrentUser(c))
|
||||
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
|
||||
}
|
||||
|
||||
func (h *ServiceHandler) ListByMachine(c *gin.Context) {
|
||||
machineID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid machine_id"})
|
||||
return
|
||||
}
|
||||
isAdmin := middleware.IsAdmin(c)
|
||||
rows, err := db.DB.Query(`SELECT id, machine_id, name, port, protocol, notes, target_machine_id, target_notes FROM services WHERE machine_id = ?`, machineID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
list := []models.Service{}
|
||||
for rows.Next() {
|
||||
var s models.Service
|
||||
var tm sql.NullInt64
|
||||
if err := rows.Scan(&s.ID, &s.MachineID, &s.Name, &s.Port, &s.Protocol, &s.Notes, &tm, &s.TargetNotes); err == nil {
|
||||
if tm.Valid {
|
||||
tmid := tm.Int64
|
||||
s.TargetMachineID = &tmid
|
||||
}
|
||||
if !isAdmin {
|
||||
s.Notes = ""
|
||||
s.TargetNotes = ""
|
||||
}
|
||||
list = append(list, s)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
func (h *ServiceHandler) ListAll(c *gin.Context) {
|
||||
rows, err := db.DB.Query(`SELECT id, machine_id, name, port, protocol, notes, target_machine_id, target_notes FROM services`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
list := []models.Service{}
|
||||
for rows.Next() {
|
||||
var s models.Service
|
||||
var tm sql.NullInt64
|
||||
if err := rows.Scan(&s.ID, &s.MachineID, &s.Name, &s.Port, &s.Protocol, &s.Notes, &tm, &s.TargetNotes); err == nil {
|
||||
if tm.Valid {
|
||||
tmid := tm.Int64
|
||||
s.TargetMachineID = &tmid
|
||||
}
|
||||
list = append(list, s)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
156
server/main.go
Normal file
156
server/main.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"lan-manager/server/config"
|
||||
"lan-manager/server/db"
|
||||
"lan-manager/server/handlers"
|
||||
"lan-manager/server/middleware"
|
||||
"lan-manager/server/services"
|
||||
)
|
||||
|
||||
//go:embed all:static
|
||||
var webFS embed.FS
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
if err := db.Init(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "db init failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
r := gin.New()
|
||||
r.RedirectTrailingSlash = false
|
||||
r.RedirectFixedPath = false
|
||||
r.Use(gin.Recovery())
|
||||
|
||||
store := cookie.NewStore([]byte(cfg.SessionSecret))
|
||||
store.Options(sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 7,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
r.Use(sessions.Sessions("lanmgr_session", store))
|
||||
|
||||
// API routes
|
||||
api := r.Group("/api")
|
||||
{
|
||||
authH := handlers.NewAuthHandler(cfg)
|
||||
api.POST("/auth/login", authH.Login)
|
||||
api.POST("/auth/logout", authH.Logout)
|
||||
api.GET("/auth/me", authH.Me)
|
||||
|
||||
api.GET("/health", handlers.NewHealthHandler().Check)
|
||||
|
||||
// Guest + Admin read-only routes
|
||||
guest := api.Group("/")
|
||||
guest.Use(middleware.OptionalAuth())
|
||||
{
|
||||
mh := handlers.NewMachineHandler()
|
||||
guest.GET("/machines", mh.List)
|
||||
}
|
||||
|
||||
// Admin only routes
|
||||
admin := api.Group("/")
|
||||
admin.Use(middleware.AuthRequired())
|
||||
{
|
||||
mh := handlers.NewMachineHandler()
|
||||
admin.GET("/machines/:id", mh.Get)
|
||||
admin.POST("/machines", mh.Create)
|
||||
admin.PUT("/machines/:id", mh.Update)
|
||||
admin.DELETE("/machines/:id", mh.Delete)
|
||||
admin.POST("/machines/:id/ssh-info", mh.SSHInfo)
|
||||
admin.POST("/machines/:id/sync-ssh", mh.SyncSSH)
|
||||
|
||||
sh := handlers.NewServiceHandler()
|
||||
admin.GET("/machines/:id/services", sh.ListByMachine)
|
||||
admin.POST("/machines/:id/services", sh.Create)
|
||||
admin.PUT("/services/:id", sh.Update)
|
||||
admin.DELETE("/services/:id", sh.Delete)
|
||||
admin.GET("/services", sh.ListAll)
|
||||
|
||||
rh := handlers.NewRelationshipHandler()
|
||||
admin.GET("/relationships", rh.List)
|
||||
admin.POST("/relationships", rh.Create)
|
||||
admin.PUT("/relationships/:id", rh.Update)
|
||||
admin.DELETE("/relationships/:id", rh.Delete)
|
||||
|
||||
admin.GET("/logs", handlers.NewLogHandler().List)
|
||||
|
||||
eh := handlers.NewExportHandler()
|
||||
admin.GET("/export", eh.Export)
|
||||
admin.POST("/import", eh.Import)
|
||||
}
|
||||
}
|
||||
|
||||
// Static files
|
||||
if cfg.WebStaticPath != "" {
|
||||
r.Static("/", cfg.WebStaticPath)
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
c.File(cfg.WebStaticPath + "/index.html")
|
||||
})
|
||||
} else {
|
||||
staticFS, err := fs.Sub(webFS, "static")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "static embed failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fileServer := http.FileServer(http.FS(staticFS))
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
if path != "/" && path != "" && !strings.HasPrefix(path, "/assets/") && path != "/favicon.ico" {
|
||||
c.Request.URL.Path = "/"
|
||||
}
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
}
|
||||
|
||||
// Start ping service
|
||||
services.StartPingService(cfg.PingInterval)
|
||||
|
||||
// Start log cleanup
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
services.CleanupLogs(ctx, cfg.LogRetentionDays)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Host + ":" + cfg.Port,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
go func() {
|
||||
fmt.Printf("Server listening on %s:%s\n", cfg.Host, cfg.Port)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||
<-quit
|
||||
fmt.Println("Shutting down server...")
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "server shutdown error: %v\n", err)
|
||||
}
|
||||
fmt.Println("Server exited")
|
||||
}
|
||||
53
server/middleware/auth.go
Normal file
53
server/middleware/auth.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const AdminSessionKey = "admin_user"
|
||||
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
user := session.Get(AdminSessionKey)
|
||||
if user == nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
c.Set("admin_user", user.(string))
|
||||
c.Set("is_admin", true)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func OptionalAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
session := sessions.Default(c)
|
||||
if user := session.Get(AdminSessionKey); user != nil {
|
||||
c.Set("admin_user", user.(string))
|
||||
c.Set("is_admin", true)
|
||||
} else {
|
||||
c.Set("is_admin", false)
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func IsAdmin(c *gin.Context) bool {
|
||||
v, exists := c.Get("is_admin")
|
||||
if !exists {
|
||||
return false
|
||||
}
|
||||
return v.(bool)
|
||||
}
|
||||
|
||||
func CurrentUser(c *gin.Context) string {
|
||||
u, _ := c.Get("admin_user")
|
||||
if u == nil {
|
||||
return ""
|
||||
}
|
||||
return u.(string)
|
||||
}
|
||||
19
server/middleware/log.go
Normal file
19
server/middleware/log.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"lan-manager/server/db"
|
||||
)
|
||||
|
||||
func LogOperation(action, entityType string, entityID *int64, entityName, oldValue, newValue, sourceIP, username string) {
|
||||
_, _ = db.DB.Exec(
|
||||
`INSERT INTO operation_logs (action, entity_type, entity_id, entity_name, old_value, new_value, source_ip, username)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
action, entityType, entityID, entityName, oldValue, newValue, sourceIP, username,
|
||||
)
|
||||
}
|
||||
|
||||
func ToJSON(v interface{}) string {
|
||||
b, _ := json.Marshal(v)
|
||||
return string(b)
|
||||
}
|
||||
114
server/models/models.go
Normal file
114
server/models/models.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
type NullString struct {
|
||||
sql.NullString
|
||||
}
|
||||
|
||||
func (ns NullString) MarshalJSON() ([]byte, error) {
|
||||
if ns.Valid {
|
||||
return json.Marshal(ns.String)
|
||||
}
|
||||
return json.Marshal("")
|
||||
}
|
||||
|
||||
func (ns *NullString) UnmarshalJSON(data []byte) error {
|
||||
if string(data) == "null" {
|
||||
ns.Valid = false
|
||||
ns.String = ""
|
||||
return nil
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
ns.String = s
|
||||
ns.Valid = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type Machine struct {
|
||||
ID int64 `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
IP string `json:"ip"`
|
||||
MAC NullString `json:"mac"`
|
||||
OsType string `json:"os_type"`
|
||||
OsVersion NullString `json:"os_version"`
|
||||
Notes NullString `json:"notes"`
|
||||
SSHPort int `json:"ssh_port"`
|
||||
SSHUsername NullString `json:"ssh_username"`
|
||||
SSHPassword NullString `json:"ssh_password"`
|
||||
IsOnline bool `json:"is_online"`
|
||||
LastPingAt *time.Time `json:"last_ping_at"`
|
||||
CPUInfo NullString `json:"cpu_info"`
|
||||
MemoryInfo NullString `json:"memory_info"`
|
||||
DiskInfo NullString `json:"disk_info"`
|
||||
Uptime NullString `json:"uptime"`
|
||||
ListenPorts NullString `json:"listen_ports"`
|
||||
SSHSyncedAt *time.Time `json:"ssh_synced_at"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ServiceCount int `json:"service_count"`
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
ID int64 `json:"id"`
|
||||
MachineID int64 `json:"machine_id"`
|
||||
Name string `json:"name"`
|
||||
Port int `json:"port"`
|
||||
Protocol string `json:"protocol"`
|
||||
Notes string `json:"notes"`
|
||||
TargetMachineID *int64 `json:"target_machine_id"`
|
||||
TargetNotes string `json:"target_notes"`
|
||||
}
|
||||
|
||||
type Relationship struct {
|
||||
ID int64 `json:"id"`
|
||||
SourceMachineID int64 `json:"source_machine_id"`
|
||||
TargetMachineID int64 `json:"target_machine_id"`
|
||||
RelationType string `json:"relation_type"`
|
||||
SourcePort *int `json:"source_port"`
|
||||
TargetPort *int `json:"target_port"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
type OperationLog struct {
|
||||
ID int64 `json:"id"`
|
||||
Action string `json:"action"`
|
||||
EntityType string `json:"entity_type"`
|
||||
EntityID *int64 `json:"entity_id"`
|
||||
EntityName string `json:"entity_name"`
|
||||
OldValue string `json:"old_value"`
|
||||
NewValue string `json:"new_value"`
|
||||
SourceIP string `json:"source_ip"`
|
||||
Username string `json:"username"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
type SSHInfoRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type SSHInfoResult struct {
|
||||
Hostname string `json:"hostname"`
|
||||
OsVersion string `json:"os_version"`
|
||||
CPU string `json:"cpu"`
|
||||
Memory string `json:"memory"`
|
||||
Disk string `json:"disk"`
|
||||
Uptime string `json:"uptime"`
|
||||
ListenPorts []int `json:"listen_ports"`
|
||||
RawCPUInfo string `json:"raw_cpu_info"`
|
||||
RawMemoryInfo string `json:"raw_memory_info"`
|
||||
RawDiskInfo string `json:"raw_disk_info"`
|
||||
}
|
||||
182
server/services/ping.go
Normal file
182
server/services/ping.go
Normal file
@@ -0,0 +1,182 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"lan-manager/server/db"
|
||||
"lan-manager/server/utils"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"net"
|
||||
|
||||
"golang.org/x/net/icmp"
|
||||
"golang.org/x/net/ipv4"
|
||||
)
|
||||
|
||||
func PingHost(ip string) bool {
|
||||
// Try system ping first (no special privileges needed on Linux)
|
||||
if ok := pingWithSystem(ip); ok {
|
||||
return true
|
||||
}
|
||||
// Fallback to raw ICMP
|
||||
return pingWithICMP(ip)
|
||||
}
|
||||
|
||||
func pingWithSystem(ip string) bool {
|
||||
var cmd *exec.Cmd
|
||||
if runtime.GOOS == "windows" {
|
||||
cmd = exec.Command("ping", "-n", "1", "-w", "3000", ip)
|
||||
} else if runtime.GOOS == "darwin" {
|
||||
// macOS ping -W is in milliseconds
|
||||
cmd = exec.Command("ping", "-c", "1", "-W", "3000", ip)
|
||||
} else {
|
||||
// Linux ping -W is in seconds
|
||||
cmd = exec.Command("ping", "-c", "1", "-W", "3", ip)
|
||||
}
|
||||
err := cmd.Run()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func pingWithICMP(ip string) bool {
|
||||
c, err := icmp.ListenPacket("ip4:icmp", "0.0.0.0")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
m := &icmp.Message{
|
||||
Type: ipv4.ICMPTypeEcho,
|
||||
Code: 0,
|
||||
Body: &icmp.Echo{ID: 1, Seq: 1, Data: []byte("lan-manager")},
|
||||
}
|
||||
wb, err := m.Marshal(nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := c.WriteTo(wb, &net.IPAddr{IP: parseIP(ip)}); err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
rb := make([]byte, 1500)
|
||||
_ = c.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
n, _, err := c.ReadFrom(rb)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rm, err := icmp.ParseMessage(ipv4.ICMPTypeEchoReply.Protocol(), rb[:n])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return rm.Type == ipv4.ICMPTypeEchoReply
|
||||
}
|
||||
|
||||
func parseIP(ip string) []byte {
|
||||
parts := strings.Split(ip, ".")
|
||||
if len(parts) != 4 {
|
||||
return nil
|
||||
}
|
||||
res := make([]byte, 4)
|
||||
for i, p := range parts {
|
||||
var v byte
|
||||
fmt.Sscanf(p, "%d", &v)
|
||||
res[i] = v
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func StartPingService(interval int) {
|
||||
ticker := time.NewTicker(time.Duration(interval) * time.Second)
|
||||
go func() {
|
||||
for range ticker.C {
|
||||
rows, err := db.DB.Query(`SELECT id, ip, ssh_port, ssh_username, ssh_password FROM machines`)
|
||||
if err != nil {
|
||||
fmt.Printf("[Ping] query error: %v\n", err)
|
||||
continue
|
||||
}
|
||||
type machinePing struct {
|
||||
id int64
|
||||
ip string
|
||||
sshPort int
|
||||
sshUsername string
|
||||
sshPassword string
|
||||
}
|
||||
list := []machinePing{}
|
||||
for rows.Next() {
|
||||
var m machinePing
|
||||
if err := rows.Scan(&m.id, &m.ip, &m.sshPort, &m.sshUsername, &m.sshPassword); err == nil {
|
||||
list = append(list, m)
|
||||
} else {
|
||||
fmt.Printf("[Ping] scan error: %v\n", err)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
fmt.Printf("[Ping] tick processed %d machines\n", len(list))
|
||||
|
||||
for _, m := range list {
|
||||
online := PingHost(m.ip)
|
||||
var onlineInt int
|
||||
if online {
|
||||
onlineInt = 1
|
||||
}
|
||||
_, dbErr := db.DB.Exec(`UPDATE machines SET is_online = ?, last_ping_at = CURRENT_TIMESTAMP WHERE id = ?`, onlineInt, m.id)
|
||||
if dbErr != nil {
|
||||
fmt.Printf("[Ping] update status error for %s: %v\n", m.ip, dbErr)
|
||||
} else {
|
||||
fmt.Printf("[Ping] %s -> online=%v\n", m.ip, online)
|
||||
}
|
||||
|
||||
// Auto fetch SSH info if credentials are saved
|
||||
if m.sshUsername != "" && m.sshPassword != "" {
|
||||
go func(mid int64, mip string, mport int, muser, mpass string) {
|
||||
plainPass, decryptErr := utils.Decrypt(mpass)
|
||||
if decryptErr != nil {
|
||||
fmt.Printf("[SSH] decrypt failed for %s:%d: %v\n", mip, mport, decryptErr)
|
||||
return
|
||||
}
|
||||
result, err := GetSSHInfo(mip, mport, muser, plainPass)
|
||||
if err != nil {
|
||||
fmt.Printf("[SSH] auto-fetch failed for %s:%d: %v\n", mip, mport, err)
|
||||
return
|
||||
}
|
||||
portsStr := ""
|
||||
if len(result.ListenPorts) > 0 {
|
||||
b, _ := json.Marshal(result.ListenPorts)
|
||||
portsStr = string(b)
|
||||
if len(portsStr) > 2 {
|
||||
portsStr = portsStr[1 : len(portsStr)-1]
|
||||
}
|
||||
}
|
||||
_, dbErr := db.DB.Exec(`UPDATE machines SET cpu_info=?, memory_info=?, disk_info=?, uptime=?, listen_ports=?, ssh_synced_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?`,
|
||||
result.RawCPUInfo, result.RawMemoryInfo, result.RawDiskInfo, result.Uptime, portsStr, mid)
|
||||
if dbErr != nil {
|
||||
fmt.Printf("[SSH] auto-fetch db error for %s:%d: %v\n", mip, mport, dbErr)
|
||||
} else {
|
||||
fmt.Printf("[SSH] auto-fetch success for %s:%d\n", mip, mport)
|
||||
}
|
||||
}(m.id, m.ip, m.sshPort, m.sshUsername, m.sshPassword)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func CleanupLogs(ctx context.Context, retentionDays int) {
|
||||
if retentionDays <= 0 {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_, _ = db.DB.Exec(`DELETE FROM operation_logs WHERE created_at < datetime('now', '-`+fmt.Sprintf("%d", retentionDays)+` days')`)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
113
server/services/ssh.go
Normal file
113
server/services/ssh.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"lan-manager/server/config"
|
||||
"lan-manager/server/models"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
func GetSSHInfo(ip string, port int, user, pass string) (*models.SSHInfoResult, error) {
|
||||
if port <= 0 {
|
||||
port = 22
|
||||
}
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: user,
|
||||
Auth: []ssh.AuthMethod{
|
||||
ssh.Password(pass),
|
||||
},
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: time.Duration(config.Load().SSHTimeout) * time.Second,
|
||||
}
|
||||
|
||||
client, err := ssh.Dial("tcp", ip+":"+strconv.Itoa(port), cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
result := &models.SSHInfoResult{}
|
||||
|
||||
// Hostname
|
||||
if out, err := runSSHCommand(client, "hostname"); err == nil {
|
||||
result.Hostname = strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// OS Version
|
||||
if out, err := runSSHCommand(client, "cat /etc/os-release | grep PRETTY_NAME | cut -d= -f2 | tr -d '\"'"); err == nil && out != "" {
|
||||
result.OsVersion = strings.TrimSpace(out)
|
||||
} else if out, err := runSSHCommand(client, "uname -sr"); err == nil {
|
||||
result.OsVersion = strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// CPU (two samples from /proc/stat for accurate real-time usage)
|
||||
cpuScript := `c1=$(awk '/^cpu /{print $2+$4,$2+$4+$5}' /proc/stat) && sleep 1 && c2=$(awk '/^cpu /{print $2+$4,$2+$4+$5}' /proc/stat) && awk 'BEGIN{split("'"$c1"'", a, " "); split("'"$c2"'", b, " "); if(b[2]-a[2]>0) printf "%.1f", 100*(b[1]-a[1])/(b[2]-a[2]); else print "0.0"}'`
|
||||
if out, err := runSSHCommand(client, cpuScript); err == nil && out != "" {
|
||||
cpuUsage := strings.TrimSpace(out)
|
||||
if cpuUsage == "" {
|
||||
cpuUsage = "0.0"
|
||||
}
|
||||
cores := ""
|
||||
if cout, err := runSSHCommand(client, "nproc"); err == nil {
|
||||
cores = strings.TrimSpace(cout)
|
||||
}
|
||||
result.CPU = cpuUsage + "%"
|
||||
if cores != "" {
|
||||
result.CPU += " (" + cores + " cores)"
|
||||
}
|
||||
result.RawCPUInfo = result.CPU
|
||||
}
|
||||
|
||||
// Memory
|
||||
if out, err := runSSHCommand(client, "free -m | awk 'NR==2{printf \"%.1f/%.1f (%.0f%%)\", $3/1024,$2/1024,$3*100/$2 }'"); err == nil && out != "" {
|
||||
result.Memory = strings.TrimSpace(out)
|
||||
result.RawMemoryInfo = result.Memory
|
||||
}
|
||||
|
||||
// Disk (exclude virtual filesystems, keep all real partitions)
|
||||
// Use df -hP to prevent long filesystem names from wrapping to next line
|
||||
if out, err := runSSHCommand(client, "df -hP | awk 'NR>1 && $1 !~ /^(tmpfs|devtmpfs|squashfs|overlay|efivarfs|tracefs|securityfs|pstore|bpf|configfs|fusectl|mqueue|hugetlbfs|rpc_pipefs|nfsd|binfmt_misc|autofs|devpts|proc|sysfs|cgroup|cgroup2|ramfs)$/ {printf \"%s %s/%s (%s) \", $6,$3,$2,$5}'"); err == nil && out != "" {
|
||||
result.Disk = strings.TrimSpace(out)
|
||||
result.RawDiskInfo = result.Disk
|
||||
}
|
||||
|
||||
// Uptime
|
||||
if out, err := runSSHCommand(client, "uptime -p 2>/dev/null || uptime | awk -F',' '{print $1}'"); err == nil {
|
||||
result.Uptime = strings.TrimSpace(out)
|
||||
}
|
||||
|
||||
// Listen ports
|
||||
if out, err := runSSHCommand(client, "ss -tlnp | awk 'NR>1 {print $4}' | sed 's/.*://' | sort -n | uniq"); err == nil && out != "" {
|
||||
ports := []int{}
|
||||
scanner := bufio.NewScanner(strings.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
p := strings.TrimSpace(scanner.Text())
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if pi, err := strconv.Atoi(p); err == nil {
|
||||
ports = append(ports, pi)
|
||||
}
|
||||
}
|
||||
result.ListenPorts = ports
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func runSSHCommand(client *ssh.Client, cmd string) (string, error) {
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer session.Close()
|
||||
var b bytes.Buffer
|
||||
session.Stdout = &b
|
||||
err = session.Run(cmd)
|
||||
return b.String(), err
|
||||
}
|
||||
78
server/utils/crypto.go
Normal file
78
server/utils/crypto.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/aes"
|
||||
"crypto/cipher"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
var encryptKey []byte
|
||||
|
||||
func init() {
|
||||
key := os.Getenv("ENCRYPT_KEY")
|
||||
if key == "" {
|
||||
key = "lan-manager-default-key-change-in-production"
|
||||
log.Println("[WARN] ENCRYPT_KEY not set, using default key. Please set ENCRYPT_KEY in production.")
|
||||
}
|
||||
h := sha256.Sum256([]byte(key))
|
||||
encryptKey = h[:]
|
||||
}
|
||||
|
||||
// Encrypt 使用 AES-GCM 加密明文,返回 base64 编码的密文
|
||||
func Encrypt(plaintext string) (string, error) {
|
||||
if plaintext == "" {
|
||||
return "", nil
|
||||
}
|
||||
block, err := aes.NewCipher(encryptKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonce := make([]byte, gcm.NonceSize())
|
||||
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
|
||||
return "", err
|
||||
}
|
||||
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
|
||||
return base64.StdEncoding.EncodeToString(ciphertext), nil
|
||||
}
|
||||
|
||||
// Decrypt 使用 AES-GCM 解密密文,输入为 base64 编码。
|
||||
// 为了兼容升级前的明文密码,如果 base64 解码失败或解密失败,则直接返回原始字符串。
|
||||
func Decrypt(ciphertext string) (string, error) {
|
||||
if ciphertext == "" {
|
||||
return "", nil
|
||||
}
|
||||
data, err := base64.StdEncoding.DecodeString(ciphertext)
|
||||
if err != nil {
|
||||
// 兼容旧明文密码
|
||||
return ciphertext, nil
|
||||
}
|
||||
block, err := aes.NewCipher(encryptKey)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
gcm, err := cipher.NewGCM(block)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
nonceSize := gcm.NonceSize()
|
||||
if len(data) < nonceSize {
|
||||
// 兼容旧明文密码
|
||||
return ciphertext, nil
|
||||
}
|
||||
nonce, cipherData := data[:nonceSize], data[nonceSize:]
|
||||
plain, err := gcm.Open(nil, nonce, cipherData, nil)
|
||||
if err != nil {
|
||||
// 兼容旧明文密码
|
||||
return ciphertext, nil
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>局域网机器管理后台</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
2049
web/package-lock.json
generated
Normal file
2049
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
web/package.json
Normal file
24
web/package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "lan-manager-web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@antv/g6": "^4.8.0",
|
||||
"@element-plus/icons-vue": "^2.3.0",
|
||||
"axios": "^1.7.0",
|
||||
"element-plus": "^2.7.0",
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"esbuild": "^0.21.5",
|
||||
"vite": "^5.2.0"
|
||||
}
|
||||
}
|
||||
120
web/src/App.vue
Normal file
120
web/src/App.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* CSS Variables */
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--surface: #ffffff;
|
||||
--text: #111827;
|
||||
--text-secondary: #4b5563;
|
||||
--text-muted: #9ca3af;
|
||||
--border: #e5e7eb;
|
||||
--primary: #3b82f6;
|
||||
--primary-weak: #eff6ff;
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--radius: 14px;
|
||||
--shadow: 0 6px 24px rgba(0,0,0,0.04);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #app {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding: 22px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
margin-bottom: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
/* Element Plus overrides for a softer look */
|
||||
.el-button {
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.el-button--primary {
|
||||
--el-button-bg-color: #3b82f6;
|
||||
--el-button-border-color: #3b82f6;
|
||||
--el-button-hover-bg-color: #2563eb;
|
||||
--el-button-hover-border-color: #2563eb;
|
||||
}
|
||||
.el-input__wrapper {
|
||||
border-radius: 10px;
|
||||
}
|
||||
.el-tag {
|
||||
border-radius: 6px;
|
||||
}
|
||||
.el-dialog {
|
||||
border-radius: 16px;
|
||||
}
|
||||
.el-dialog__header {
|
||||
padding: 18px 20px 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.el-dialog__body {
|
||||
padding: 10px 20px 18px;
|
||||
}
|
||||
.el-table {
|
||||
--el-table-header-bg-color: #f9fafb;
|
||||
--el-table-row-hover-bg-color: #f3f4f6;
|
||||
--el-table-border-color: var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.el-table th.el-table__cell {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.el-pagination .btn-prev,
|
||||
.el-pagination .btn-next,
|
||||
.el-pager li {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.12);
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
</style>
|
||||
63
web/src/api/index.js
Normal file
63
web/src/api/index.js
Normal file
@@ -0,0 +1,63 @@
|
||||
import axios from 'axios'
|
||||
import { refreshAuth } from '@/router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: '/api',
|
||||
timeout: 30000,
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
const msg = err.response?.data?.error || err.message || '请求失败'
|
||||
if (err.response?.status === 401) {
|
||||
refreshAuth()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(err)
|
||||
}
|
||||
ElMessage.error(msg)
|
||||
return Promise.reject(err)
|
||||
}
|
||||
)
|
||||
|
||||
export default api
|
||||
|
||||
export let uiRefreshInterval = 10000
|
||||
|
||||
export const checkAuth = () => api.get('/auth/me').then(res => {
|
||||
if (res.data && typeof res.data.ui_refresh_interval === 'number') {
|
||||
uiRefreshInterval = res.data.ui_refresh_interval
|
||||
}
|
||||
return res
|
||||
})
|
||||
export const login = (data) => api.post('/auth/login', data)
|
||||
export const guestLogin = () => api.post('/auth/guest')
|
||||
export const logout = () => api.post('/auth/logout')
|
||||
|
||||
export const fetchMachines = (params) => api.get('/machines', { params })
|
||||
export const fetchMachine = (id) => api.get(`/machines/${id}`)
|
||||
export const createMachine = (data) => api.post('/machines', data)
|
||||
export const updateMachine = (id, data) => api.put(`/machines/${id}`, data)
|
||||
export const deleteMachine = (id) => api.delete(`/machines/${id}`)
|
||||
export const sshInfo = (id, data) => api.post(`/machines/${id}/ssh-info`, data)
|
||||
export const syncSSH = (id, data) => api.post(`/machines/${id}/sync-ssh`, data)
|
||||
|
||||
export const fetchServices = (machineId) => api.get(`/machines/${machineId}/services`)
|
||||
export const fetchAllServices = () => api.get('/services')
|
||||
export const createService = (machineId, data) => api.post(`/machines/${machineId}/services`, data)
|
||||
export const updateService = (id, data) => api.put(`/services/${id}`, data)
|
||||
export const deleteService = (id) => api.delete(`/services/${id}`)
|
||||
|
||||
export const fetchRelationships = () => api.get('/relationships')
|
||||
export const createRelationship = (data) => api.post('/relationships', data)
|
||||
export const updateRelationship = (id, data) => api.put(`/relationships/${id}`, data)
|
||||
export const deleteRelationship = (id) => api.delete(`/relationships/${id}`)
|
||||
|
||||
export const fetchLogs = (params) => api.get('/logs', { params })
|
||||
|
||||
export const exportData = () => api.get('/export', { responseType: 'blob' })
|
||||
export const importData = (formData) => api.post('/import', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
197
web/src/components/MainLayout.vue
Normal file
197
web/src/components/MainLayout.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<template>
|
||||
<div class="layout">
|
||||
<aside v-if="isAdmin" class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-logo">LM</div>
|
||||
<div class="brand-text">LAN Manager</div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<router-link to="/machines" class="nav-item" active-class="active">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>机器列表</span>
|
||||
</router-link>
|
||||
<router-link v-if="isAdmin" to="/topology" class="nav-item" active-class="active">
|
||||
<el-icon><Share /></el-icon>
|
||||
<span>拓扑图</span>
|
||||
</router-link>
|
||||
<router-link v-if="isAdmin" to="/logs" class="nav-item" active-class="active">
|
||||
<el-icon><Document /></el-icon>
|
||||
<span>操作日志</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="user-info">
|
||||
<el-icon class="user-icon"><User /></el-icon>
|
||||
<span class="user-name">{{ isAdmin ? '管理员' : '访客' }}</span>
|
||||
</div>
|
||||
<el-button text class="logout-btn" @click="logout">
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>退出</span>
|
||||
</el-button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main" :class="{ 'no-sidebar': !isAdmin }">
|
||||
<div class="main-inner">
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Monitor, Share, Document, User, SwitchButton } from '@element-plus/icons-vue'
|
||||
import { getAuth, refreshAuth } from '@/router'
|
||||
import { logout as apiLogout } from '@/api'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const isAdmin = computed(() => getAuth().is_admin)
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await apiLogout()
|
||||
} catch (e) {}
|
||||
refreshAuth()
|
||||
ElMessage.success('已退出')
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 220px;
|
||||
background: #0f172a;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.brand {
|
||||
padding: 22px 18px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-bottom: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.brand-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #3b82f6, #60a5fa);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
}
|
||||
.brand-text {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
flex: 1;
|
||||
padding: 12px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
color: rgba(255,255,255,0.65);
|
||||
text-decoration: none;
|
||||
font-size: 13px;
|
||||
transition: background .15s ease, color .15s ease;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: rgba(255,255,255,0.9);
|
||||
}
|
||||
.nav-item.active {
|
||||
background: rgba(59, 130, 246, 0.18);
|
||||
color: #60a5fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-item .el-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 12px 10px 16px;
|
||||
border-top: 1px solid rgba(255,255,255,0.06);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
color: rgba(255,255,255,0.55);
|
||||
}
|
||||
.user-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
.user-name {
|
||||
font-weight: 500;
|
||||
color: rgba(255,255,255,0.85);
|
||||
}
|
||||
.logout-btn {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
color: rgba(255,255,255,0.55);
|
||||
padding: 8px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.logout-btn:hover {
|
||||
color: #fff;
|
||||
background: rgba(255,255,255,0.06);
|
||||
}
|
||||
.logout-btn span {
|
||||
margin-left: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
margin-left: 220px;
|
||||
min-height: 100vh;
|
||||
background: var(--bg);
|
||||
}
|
||||
.main-inner {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.main.no-sidebar {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { width: 64px; }
|
||||
.brand-text, .nav-item span, .user-name, .logout-btn span { display: none; }
|
||||
.brand { justify-content: center; padding: 18px 10px; }
|
||||
.nav-item { justify-content: center; }
|
||||
.main { margin-left: 64px; }
|
||||
.main.no-sidebar { margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
16
web/src/main.js
Normal file
16
web/src/main.js
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, component)
|
||||
}
|
||||
|
||||
app.use(ElementPlus)
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
89
web/src/router/index.js
Normal file
89
web/src/router/index.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { checkAuth } from '@/api'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'Login',
|
||||
component: () => import('@/views/Login.vue'),
|
||||
meta: { public: true, guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/components/MainLayout.vue'),
|
||||
children: [
|
||||
{ path: '', redirect: '/machines' },
|
||||
{
|
||||
path: 'machines',
|
||||
name: 'MachineList',
|
||||
component: () => import('@/views/MachineList.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: 'machines/:id',
|
||||
name: 'MachineDetail',
|
||||
component: () => import('@/views/MachineDetail.vue'),
|
||||
meta: { public: true },
|
||||
},
|
||||
{
|
||||
path: 'topology',
|
||||
name: 'Topology',
|
||||
component: () => import('@/views/Topology.vue'),
|
||||
meta: { admin: true },
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
name: 'Logs',
|
||||
component: () => import('@/views/Logs.vue'),
|
||||
meta: { admin: true },
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
let authChecked = false
|
||||
let authState = { is_admin: false }
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
if (!authChecked) {
|
||||
try {
|
||||
const { data } = await checkAuth()
|
||||
authState = data
|
||||
} catch (e) {
|
||||
authState = { is_admin: false }
|
||||
}
|
||||
authChecked = true
|
||||
}
|
||||
|
||||
// 访客禁止进入机器详情页
|
||||
if (to.name === 'MachineDetail' && !authState.is_admin) {
|
||||
return next('/machines')
|
||||
}
|
||||
|
||||
if (to.meta.admin && !authState.is_admin) {
|
||||
return next('/login?redirect=' + encodeURIComponent(to.fullPath))
|
||||
}
|
||||
if (to.meta.guestOnly && authState.is_admin) {
|
||||
return next('/')
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export function refreshAuth() {
|
||||
authChecked = false
|
||||
}
|
||||
|
||||
export function setAuth(data) {
|
||||
authState = data
|
||||
}
|
||||
|
||||
export function getAuth() {
|
||||
return authState
|
||||
}
|
||||
|
||||
export default router
|
||||
189
web/src/views/Login.vue
Normal file
189
web/src/views/Login.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<div class="logo">LM</div>
|
||||
<h1>LAN Manager</h1>
|
||||
<p>轻量级局域网资产管理平台</p>
|
||||
</div>
|
||||
<el-form :model="form" class="login-form">
|
||||
<el-form-item>
|
||||
<div class="input-wrap">
|
||||
<el-icon class="input-icon"><User /></el-icon>
|
||||
<el-input v-model="form.username" placeholder="用户名" size="large" @keydown.enter="login" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="input-wrap">
|
||||
<el-icon class="input-icon"><Lock /></el-icon>
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="密码" size="large" @keydown.enter="login" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-button type="primary" size="large" class="login-btn" :loading="loading" @click="login">登录</el-button>
|
||||
<div class="login-guest">
|
||||
<el-button text size="small" @click="guestLogin">访客模式进入</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</div>
|
||||
<div class="login-footer">
|
||||
© LAN Manager
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { User, Lock } from '@element-plus/icons-vue'
|
||||
import { login as apiLogin, guestLogin as apiGuest } from '@/api'
|
||||
import { setAuth } from '@/router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const form = ref({ username: '', password: '' })
|
||||
const loading = ref(false)
|
||||
|
||||
async function login() {
|
||||
if (!form.value.username || !form.value.password) {
|
||||
ElMessage.warning('请输入用户名和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiLogin(form.value)
|
||||
setAuth(res.data)
|
||||
ElMessage.success('登录成功')
|
||||
router.push('/')
|
||||
} catch (e) {}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function guestLogin() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await apiGuest()
|
||||
setAuth(res.data)
|
||||
ElMessage.success('已进入访客模式')
|
||||
router.push('/')
|
||||
} catch (e) {}
|
||||
loading.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 20px;
|
||||
padding: 36px;
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, #3b82f6, #60a5fa);
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 22px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 14px;
|
||||
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.35);
|
||||
}
|
||||
.login-header h1 {
|
||||
color: #fff;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
.login-header p {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
width: 100%;
|
||||
}
|
||||
.login-form :deep(.el-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.login-form :deep(.el-form-item__content) {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
.input-wrap :deep(.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
.input-wrap :deep(.el-input__wrapper) {
|
||||
padding-left: 38px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.1) inset;
|
||||
}
|
||||
.input-wrap :deep(.el-input__inner) {
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
}
|
||||
.input-wrap :deep(.el-input__inner::placeholder) {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
border-radius: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.login-guest {
|
||||
text-align: center;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.login-guest :deep(.el-button) {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
.login-guest :deep(.el-button:hover) {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 24px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
</style>
|
||||
111
web/src/views/Logs.vue
Normal file
111
web/src/views/Logs.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">操作日志</div>
|
||||
<div class="page-subtitle">查看最近的系统操作记录</div>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<el-input v-model="search" placeholder="搜索操作内容" clearable style="width: 240px" />
|
||||
<el-button type="primary" @click="load">查询</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-header">共 {{ total }} 条记录</div>
|
||||
<el-table :data="logs" stripe style="width: 100%" v-loading="loading" class="modern-table">
|
||||
<el-table-column prop="id" label="ID" width="70" />
|
||||
<el-table-column prop="action" label="操作" width="140" />
|
||||
<el-table-column prop="target_type" label="对象" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain" round>{{ row.target_type }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="target_name" label="对象名称" min-width="160" />
|
||||
<el-table-column prop="details" label="详情" min-width="240" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="时间" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination-bar">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next"
|
||||
:total="total"
|
||||
@change="load"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { fetchLogs } from '@/api'
|
||||
|
||||
const logs = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const search = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => load())
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
const res = await fetchLogs({ page: page.value, page_size: pageSize.value, search: search.value })
|
||||
logs.value = res.data.list
|
||||
total.value = res.data.total
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t) return '-'
|
||||
const d = new Date(t)
|
||||
if (isNaN(d.getTime())) return t
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.page-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pagination-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 14px;
|
||||
}
|
||||
</style>
|
||||
813
web/src/views/MachineDetail.vue
Normal file
813
web/src/views/MachineDetail.vue
Normal file
@@ -0,0 +1,813 @@
|
||||
<template>
|
||||
<div class="page" v-if="machine">
|
||||
<!-- Header -->
|
||||
<div class="detail-header">
|
||||
<div class="header-left">
|
||||
<el-button text circle @click="$router.back()">
|
||||
<el-icon><ArrowLeft /></el-icon>
|
||||
</el-button>
|
||||
<div>
|
||||
<div class="host-title">
|
||||
{{ machine.hostname }}
|
||||
<span class="status-badge" :class="machine.is_online ? 'online' : 'offline'">
|
||||
{{ machine.is_online ? '在线' : '离线' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="host-subtitle">
|
||||
<span v-if="isAdmin">{{ machine.ip }}</span>
|
||||
<span>{{ machine.os_type }}</span>
|
||||
<span v-if="machine.os_version">{{ machine.os_version }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions" v-if="isAdmin">
|
||||
<el-button text :icon="Edit" @click="openEdit(machine)">编辑</el-button>
|
||||
<el-button text type="danger" :icon="Delete" @click="delMachine">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<!-- Basic Info -->
|
||||
<div class="card">
|
||||
<div class="card-title">基本信息</div>
|
||||
<div class="info-list">
|
||||
<div class="info-item"><span class="info-label">IP</span><span class="info-value">{{ machine.ip }}</span></div>
|
||||
<div class="info-item" v-if="isAdmin"><span class="info-label">MAC</span><span class="info-value">{{ machine.mac || '-' }}</span></div>
|
||||
<div class="info-item"><span class="info-label">系统</span><span class="info-value">{{ machine.os_type }} {{ machine.os_version || '' }}</span></div>
|
||||
<div class="info-item" v-if="isAdmin"><span class="info-label">SSH 端口</span><span class="info-value">{{ machine.ssh_port || 22 }}</span></div>
|
||||
<div class="info-item" v-if="isAdmin"><span class="info-label">备注</span><span class="info-value text-muted">{{ machine.notes || '-' }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Status -->
|
||||
<div class="card" v-if="machine.cpu_info || machine.memory_info || machine.disk_info">
|
||||
<div class="card-title">系统状态</div>
|
||||
<div class="status-pills">
|
||||
<div v-if="machine.cpu_info" class="status-pill">
|
||||
<div class="sp-icon cpu">CPU</div>
|
||||
<div class="sp-info">
|
||||
<div class="sp-title">{{ machine.cpu_info }}</div>
|
||||
<div class="sp-bar"><div class="sp-fill" :style="{ width: extractPercent(machine.cpu_info) + '%' }"></div></div>
|
||||
</div>
|
||||
<div class="sp-num">{{ extractPercent(machine.cpu_info) }}%</div>
|
||||
</div>
|
||||
<div v-if="machine.memory_info" class="status-pill">
|
||||
<div class="sp-icon mem">MEM</div>
|
||||
<div class="sp-info">
|
||||
<div class="sp-title">{{ machine.memory_info }}</div>
|
||||
<div class="sp-bar"><div class="sp-fill" :style="{ width: extractPercent(machine.memory_info) + '%', background: getMemBarColor(extractPercent(machine.memory_info)) }"></div></div>
|
||||
</div>
|
||||
<div class="sp-num">{{ extractPercent(machine.memory_info) }}%</div>
|
||||
</div>
|
||||
<div v-if="machine.disk_info" class="status-pill disk-multi">
|
||||
<template v-if="parseDisks(machine.disk_info).length <= 1">
|
||||
<div class="sp-icon disk">DISK</div>
|
||||
<div class="sp-info">
|
||||
<div class="sp-title">{{ machine.disk_info }}</div>
|
||||
<div class="sp-bar"><div class="sp-fill" :style="{ width: extractPercent(machine.disk_info) + '%', background: getDiskBarColor(extractPercent(machine.disk_info)) }"></div></div>
|
||||
</div>
|
||||
<div class="sp-num">{{ extractPercent(machine.disk_info) }}%</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="sp-icon disk">DISK</div>
|
||||
<div class="sp-info multi-disk-info">
|
||||
<div v-for="(d, idx) in parseDisks(machine.disk_info)" :key="idx" class="detail-disk-item">
|
||||
<span class="dd-mount">{{ d.mount }}</span>
|
||||
<div class="dd-bar-wrap">
|
||||
<div class="dd-bar"><div class="dd-fill" :style="{ width: d.percent + '%', background: getDiskBarColor(d.percent) }"></div></div>
|
||||
</div>
|
||||
<span class="dd-val">{{ d.detail }}</span>
|
||||
<span class="dd-pct">{{ d.percent }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="machine.uptime" class="uptime-line">
|
||||
<el-icon><Timer /></el-icon>
|
||||
<span>运行时间 {{ machine.uptime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSH Section -->
|
||||
<div class="card" v-if="isAdmin">
|
||||
<div class="card-title">SSH 系统信息</div>
|
||||
<div v-if="machine.cpu_info || machine.memory_info || machine.disk_info || machine.uptime" class="sync-actions">
|
||||
<el-tag size="small" effect="plain" round>上次同步 {{ formatTime(machine.ssh_synced_at) }}</el-tag>
|
||||
</div>
|
||||
<div class="ssh-actions">
|
||||
<el-button type="primary" :icon="Refresh" @click="openSSH">获取系统信息</el-button>
|
||||
</div>
|
||||
<div v-if="sshResult" class="ssh-result">
|
||||
<div class="result-grid">
|
||||
<div class="result-item"><span class="rl">主机名</span><span class="rv">{{ sshResult.hostname }}</span></div>
|
||||
<div class="result-item"><span class="rl">系统版本</span><span class="rv">{{ sshResult.os_version }}</span></div>
|
||||
<div class="result-item"><span class="rl">CPU</span><span class="rv">{{ sshResult.cpu }}</span></div>
|
||||
<div class="result-item"><span class="rl">内存</span><span class="rv">{{ sshResult.memory }}</span></div>
|
||||
<div class="result-item"><span class="rl">磁盘</span><span class="rv">{{ sshResult.disk }}</span></div>
|
||||
<div class="result-item"><span class="rl">运行时间</span><span class="rv">{{ sshResult.uptime }}</span></div>
|
||||
<div class="result-item full"><span class="rl">监听端口</span><span class="rv">{{ sshResult.listen_ports?.join(', ') }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Services -->
|
||||
<div class="card">
|
||||
<div class="card-title">
|
||||
服务列表
|
||||
<el-button v-if="isAdmin" size="small" type="primary" :icon="Plus" @click="openServiceEdit()">添加服务</el-button>
|
||||
</div>
|
||||
<div v-if="services.length" class="service-list">
|
||||
<div v-for="s in services" :key="s.id" class="service-row">
|
||||
<div class="service-main">
|
||||
<div class="service-name">{{ s.name }}</div>
|
||||
<div class="service-port">{{ s.port }} / {{ s.protocol }}</div>
|
||||
</div>
|
||||
<div class="service-target" v-if="isAdmin && s.target_machine_id">
|
||||
<el-icon><Right /></el-icon>
|
||||
<span>{{ machineMap[s.target_machine_id] || s.target_machine_id }}</span>
|
||||
<span v-if="s.target_notes" class="target-note">{{ s.target_notes }}</span>
|
||||
</div>
|
||||
<div class="service-note" v-if="isAdmin && s.notes">{{ s.notes }}</div>
|
||||
<div class="service-actions" v-if="isAdmin">
|
||||
<el-button text size="small" :icon="Edit" @click.stop="openServiceEdit(s)">编辑</el-button>
|
||||
<el-button text size="small" type="danger" :icon="Delete" @click.stop="delService(s)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无服务" :image-size="80" />
|
||||
</div>
|
||||
|
||||
<!-- Relationships -->
|
||||
<div class="card" v-if="isAdmin">
|
||||
<div class="card-title">
|
||||
关联关系
|
||||
<el-button size="small" type="primary" :icon="Plus" @click="openRelEdit()">添加关系</el-button>
|
||||
</div>
|
||||
<div v-if="relationships.length" class="rel-list">
|
||||
<div v-for="r in relationships" :key="r.id" class="rel-row">
|
||||
<div class="rel-arrow">
|
||||
<span class="rel-host">{{ r.source_hostname }}</span>
|
||||
<el-icon><Right /></el-icon>
|
||||
<span class="rel-host">{{ r.target_hostname }}</span>
|
||||
</div>
|
||||
<div class="rel-meta">
|
||||
<el-tag size="small" effect="plain" round>{{ typeText(r.relation_type) }}</el-tag>
|
||||
<span v-if="r.source_port" class="rel-port">{{ r.source_port }} → {{ r.target_port || '-' }}</span>
|
||||
<span v-if="r.notes" class="rel-note">{{ r.notes }}</span>
|
||||
</div>
|
||||
<div class="rel-actions">
|
||||
<el-button text size="small" :icon="Edit" @click="openRelEdit(r)">编辑</el-button>
|
||||
<el-button text size="small" type="danger" :icon="Delete" @click="delRel(r)">删除</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else description="暂无关系" :image-size="80" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSH Dialog -->
|
||||
<el-dialog v-model="sshDialogVisible" title="SSH 获取系统信息" width="360px" class="modern-dialog">
|
||||
<el-form :model="sshForm" label-width="80px">
|
||||
<el-form-item label="用户名">
|
||||
<el-input v-model="sshForm.username" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="sshForm.password" type="password" show-password />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="sshDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="sshLoading" @click="fetchSSH">连接</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Machine Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" title="编辑机器" width="480px" class="modern-dialog">
|
||||
<el-form :model="editing" label-width="90px">
|
||||
<el-form-item label="主机名" required><el-input v-model="editing.hostname" /></el-form-item>
|
||||
<el-form-item label="IP" required><el-input v-model="editing.ip" /></el-form-item>
|
||||
<el-form-item label="MAC"><el-input v-model="editing.mac" /></el-form-item>
|
||||
<el-form-item label="SSH端口"><el-input-number v-model="editing.ssh_port" :min="1" :max="65535" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="SSH用户"><el-input v-model="editing.ssh_username" /></el-form-item>
|
||||
<el-form-item label="SSH密码"><el-input v-model="editing.ssh_password" type="password" show-password placeholder="输入则更新密码,留空保持不变" /></el-form-item>
|
||||
<el-form-item label="系统" required>
|
||||
<el-select v-model="editing.os_type" style="width:100%">
|
||||
<el-option label="Linux" value="Linux" />
|
||||
<el-option label="Windows" value="Windows" />
|
||||
<el-option label="macOS" value="macOS" />
|
||||
<el-option label="Other" value="Other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="系统版本"><el-input v-model="editing.os_version" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="editing.notes" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveMachine">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Service Edit Dialog -->
|
||||
<el-dialog v-model="serviceDialogVisible" title="编辑服务" width="440px" class="modern-dialog">
|
||||
<el-form :model="serviceEditing" label-width="90px">
|
||||
<el-form-item label="名称" required><el-input v-model="serviceEditing.name" /></el-form-item>
|
||||
<el-form-item label="端口" required><el-input-number v-model="serviceEditing.port" :min="1" :max="65535" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="协议">
|
||||
<el-select v-model="serviceEditing.protocol" style="width:100%">
|
||||
<el-option label="TCP" value="TCP" />
|
||||
<el-option label="UDP" value="UDP" />
|
||||
<el-option label="HTTP" value="HTTP" />
|
||||
<el-option label="HTTPS" value="HTTPS" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="指向机器">
|
||||
<el-select v-model="serviceEditing.target_machine_id" clearable style="width:100%" placeholder="选择目标机器(拓扑连线用)">
|
||||
<el-option v-for="m in allMachines.filter(x => x.id != machineId)" :key="m.id" :label="m.hostname" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="指向备注">
|
||||
<el-input v-model="serviceEditing.target_notes" placeholder="如:反向代理到目标" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="serviceEditing.notes" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="serviceDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveService">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Relationship Edit Dialog -->
|
||||
<el-dialog v-model="relDialogVisible" title="编辑关系" width="440px" class="modern-dialog">
|
||||
<el-form :model="relEditing" label-width="100px">
|
||||
<el-form-item label="源机器" required>
|
||||
<el-select v-model="relEditing.source_machine_id" style="width:100%">
|
||||
<el-option v-for="m in allMachines" :key="m.id" :label="m.hostname" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标机器" required>
|
||||
<el-select v-model="relEditing.target_machine_id" style="width:100%">
|
||||
<el-option v-for="m in allMachines" :key="m.id" :label="m.hostname" :value="m.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关系类型" required>
|
||||
<el-select v-model="relEditing.relation_type" style="width:100%">
|
||||
<el-option label="端口转发" value="port_forward" />
|
||||
<el-option label="依赖" value="dependency" />
|
||||
<el-option label="主从" value="primary_secondary" />
|
||||
<el-option label="自定义" value="custom" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="源端口"><el-input-number v-model="relEditing.source_port" :min="1" :max="65535" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="目标端口"><el-input-number v-model="relEditing.target_port" :min="1" :max="65535" style="width:100%" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="relEditing.notes" type="textarea" :rows="2" /></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="relDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveRel">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Edit, Delete, Plus, Refresh, Right, Timer } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchMachine, updateMachine, deleteMachine,
|
||||
fetchServices, createService, updateService, deleteService,
|
||||
fetchRelationships, createRelationship, updateRelationship, deleteRelationship,
|
||||
sshInfo, syncSSH, fetchMachines,
|
||||
checkAuth, uiRefreshInterval,
|
||||
} from '@/api'
|
||||
import { getAuth } from '@/router'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const isAdmin = getAuth().is_admin
|
||||
const machineId = route.params.id
|
||||
|
||||
const machine = ref(null)
|
||||
const services = ref([])
|
||||
const relationships = ref([])
|
||||
const allMachines = ref([])
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref({})
|
||||
const sshDialogVisible = ref(false)
|
||||
const sshForm = ref({ username: '', password: '' })
|
||||
const sshResult = ref(null)
|
||||
const sshLoading = ref(false)
|
||||
|
||||
const serviceDialogVisible = ref(false)
|
||||
const serviceEditing = ref({})
|
||||
|
||||
const relDialogVisible = ref(false)
|
||||
const relEditing = ref({})
|
||||
|
||||
const machineMap = computed(() => {
|
||||
const map = {}
|
||||
allMachines.value.forEach(m => map[m.id] = m.hostname)
|
||||
return map
|
||||
})
|
||||
|
||||
let timer = null
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMachine()
|
||||
await loadServices()
|
||||
if (isAdmin) {
|
||||
await loadRelationships()
|
||||
const res = await fetchMachines()
|
||||
allMachines.value = res.data
|
||||
}
|
||||
await checkAuth()
|
||||
const interval = uiRefreshInterval || 10000
|
||||
if (interval > 0) {
|
||||
timer = setInterval(async () => {
|
||||
await loadMachine()
|
||||
await loadServices()
|
||||
if (isAdmin) {
|
||||
await loadRelationships()
|
||||
}
|
||||
}, interval)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
async function loadMachine() {
|
||||
const res = await fetchMachine(machineId)
|
||||
machine.value = res.data.machine
|
||||
// 如果已有同步过的 SSH 信息,直接展示上次结果
|
||||
if (machine.value && (machine.value.cpu_info || machine.value.memory_info || machine.value.disk_info || machine.value.uptime)) {
|
||||
sshResult.value = {
|
||||
hostname: machine.value.hostname,
|
||||
os_version: machine.value.os_version,
|
||||
cpu: machine.value.cpu_info,
|
||||
memory: machine.value.memory_info,
|
||||
disk: machine.value.disk_info,
|
||||
uptime: machine.value.uptime,
|
||||
listen_ports: machine.value.listen_ports ? machine.value.listen_ports.split(',').map(s => s.trim()).filter(Boolean) : []
|
||||
}
|
||||
} else {
|
||||
sshResult.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServices() {
|
||||
const res = await fetchServices(machineId)
|
||||
services.value = res.data
|
||||
}
|
||||
|
||||
async function loadRelationships() {
|
||||
const res = await fetchRelationships()
|
||||
const map = {}
|
||||
allMachines.value.forEach(m => map[m.id] = m.hostname)
|
||||
relationships.value = res.data
|
||||
.filter(r => r.source_machine_id == machineId || r.target_machine_id == machineId)
|
||||
.map(r => ({
|
||||
...r,
|
||||
source_hostname: map[r.source_machine_id] || r.source_machine_id,
|
||||
target_hostname: map[r.target_machine_id] || r.target_machine_id
|
||||
}))
|
||||
}
|
||||
|
||||
function openEdit(item) {
|
||||
editing.value = { ...item, ssh_port: item.ssh_port || 22, ssh_username: item.ssh_username || '', ssh_password: '' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function openSSH() {
|
||||
// 如果已保存用户名,先尝试用数据库里的凭据自动获取
|
||||
if (machine.value.ssh_username) {
|
||||
sshLoading.value = true
|
||||
try {
|
||||
const res = await sshInfo(machineId, { username: machine.value.ssh_username, password: '' })
|
||||
sshResult.value = res.data
|
||||
await syncSSH(machineId, sshResult.value)
|
||||
ElMessage.success('获取并同步成功')
|
||||
await loadMachine()
|
||||
sshLoading.value = false
|
||||
return
|
||||
} catch (e) {
|
||||
sshLoading.value = false
|
||||
// 自动获取失败,继续弹出对话框让用户手动输入
|
||||
}
|
||||
}
|
||||
sshForm.value = {
|
||||
username: machine.value.ssh_username || '',
|
||||
password: ''
|
||||
}
|
||||
sshDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveMachine() {
|
||||
await updateMachine(editing.value.id, editing.value)
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
loadMachine()
|
||||
}
|
||||
|
||||
async function delMachine() {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该机器?', '提示', { type: 'warning' })
|
||||
await deleteMachine(machineId)
|
||||
ElMessage.success('删除成功')
|
||||
router.back()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function fetchSSH() {
|
||||
sshLoading.value = true
|
||||
try {
|
||||
const res = await sshInfo(machineId, sshForm.value)
|
||||
sshResult.value = res.data
|
||||
sshDialogVisible.value = false
|
||||
await syncSSH(machineId, sshResult.value)
|
||||
ElMessage.success('获取并同步成功')
|
||||
await loadMachine()
|
||||
} catch (e) {}
|
||||
sshLoading.value = false
|
||||
}
|
||||
|
||||
function openServiceEdit(row) {
|
||||
serviceEditing.value = row
|
||||
? { ...row, target_machine_id: row.target_machine_id || null, target_notes: row.target_notes || '' }
|
||||
: { name: '', port: 80, protocol: 'TCP', notes: '', target_machine_id: null, target_notes: '' }
|
||||
serviceDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveService() {
|
||||
const data = { ...serviceEditing.value }
|
||||
if (data.id) {
|
||||
await updateService(data.id, data)
|
||||
} else {
|
||||
await createService(machineId, data)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
serviceDialogVisible.value = false
|
||||
loadServices()
|
||||
}
|
||||
|
||||
async function delService(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该服务?', '提示', { type: 'warning' })
|
||||
await deleteService(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadServices()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function openRelEdit(row) {
|
||||
relEditing.value = row
|
||||
? { ...row }
|
||||
: { source_machine_id: Number(machineId), target_machine_id: null, relation_type: 'dependency', source_port: null, target_port: null, notes: '' }
|
||||
relDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveRel() {
|
||||
const data = { ...relEditing.value }
|
||||
if (data.id) {
|
||||
await updateRelationship(data.id, data)
|
||||
} else {
|
||||
await createRelationship(data)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
relDialogVisible.value = false
|
||||
loadRelationships()
|
||||
}
|
||||
|
||||
async function delRel(row) {
|
||||
try {
|
||||
await ElMessageBox.confirm('确定删除该关系?', '提示', { type: 'warning' })
|
||||
await deleteRelationship(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadRelationships()
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function typeText(t) {
|
||||
const map = { port_forward: '端口转发', dependency: '依赖', primary_secondary: '主从', custom: '自定义' }
|
||||
return map[t] || t
|
||||
}
|
||||
|
||||
function extractPercent(str) {
|
||||
if (!str) return 0
|
||||
const m = str.match(/\((\d+)%\)/) || str.match(/(\d+(?:\.\d+)?)%/)
|
||||
if (m) {
|
||||
const v = parseFloat(m[1])
|
||||
return isNaN(v) ? 0 : Math.min(100, Math.max(0, Math.round(v)))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function getMemBarColor(v) {
|
||||
if (v >= 90) return '#f87171'
|
||||
if (v >= 70) return '#fbbf24'
|
||||
return '#34d399'
|
||||
}
|
||||
function getDiskBarColor(v) {
|
||||
if (v >= 90) return '#f87171'
|
||||
if (v >= 80) return '#fbbf24'
|
||||
return '#60a5fa'
|
||||
}
|
||||
|
||||
function parseDisks(str) {
|
||||
if (!str) return []
|
||||
const disks = []
|
||||
const regex = /(\S+)\s+([\d\.]+[KMGT]?\/[\d\.]+[KMGT]?)\s+\((\d+)%\)/g
|
||||
let m
|
||||
while ((m = regex.exec(str)) !== null) {
|
||||
disks.push({ mount: m[1], detail: m[2], percent: parseInt(m[3], 10) })
|
||||
}
|
||||
if (disks.length > 0) {
|
||||
return disks
|
||||
}
|
||||
// fallback: single disk old format
|
||||
const pct = extractPercent(str)
|
||||
const det = extractDetail(str)
|
||||
if (det) {
|
||||
return [{ mount: '', detail: det, percent: pct }]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t) return '-'
|
||||
const d = new Date(t)
|
||||
if (isNaN(d.getTime())) return t
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
gap: 12px;
|
||||
}
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
.host-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.status-badge {
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.status-badge.online { background: #dcfce7; color: #166534; }
|
||||
.status-badge.offline { background: #fee2e2; color: #991b1b; }
|
||||
.host-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.host-subtitle span + span::before {
|
||||
content: '·';
|
||||
margin-right: 8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
.detail-grid > .card {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.detail-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
.info-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.info-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.info-label { color: var(--text-secondary); }
|
||||
.info-value { font-weight: 500; color: var(--text); }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
|
||||
.status-pills {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.status-pill {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background: #f9fafb;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.sp-icon {
|
||||
width: 36px; height: 36px; border-radius: 8px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; font-weight: 800; color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.sp-icon.cpu { background: linear-gradient(135deg, #3b82f6, #60a5fa); }
|
||||
.sp-icon.mem { background: linear-gradient(135deg, #22c55e, #4ade80); }
|
||||
.sp-icon.disk { background: linear-gradient(135deg, #f59e0b, #fbbf24); }
|
||||
.sp-info { flex: 1; min-width: 0; }
|
||||
.sp-title { font-size: 12px; color: var(--text-secondary); margin-bottom: 6px; }
|
||||
.sp-bar { height: 5px; background: #e5e7eb; border-radius: 3px; overflow: hidden; }
|
||||
.sp-fill { height: 100%; border-radius: 3px; background: #3b82f6; transition: width .3s ease; }
|
||||
.sp-num { font-size: 14px; font-weight: 700; color: var(--text); min-width: 36px; text-align: right; }
|
||||
|
||||
.status-pill.disk-multi { flex-wrap: wrap; }
|
||||
.multi-disk-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
max-height: 220px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.detail-disk-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.dd-mount {
|
||||
min-width: 50px;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
.dd-bar-wrap {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
}
|
||||
.dd-bar {
|
||||
height: 5px;
|
||||
background: #e5e7eb;
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dd-fill { height: 100%; border-radius: 3px; }
|
||||
.dd-val { white-space: nowrap; color: var(--text-secondary); }
|
||||
.dd-pct { min-width: 32px; text-align: right; font-weight: 700; color: var(--text); }
|
||||
|
||||
.uptime-line {
|
||||
margin-top: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 10px;
|
||||
background: #f9fafb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sync-actions {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.ssh-actions {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ssh-result {
|
||||
background: #f9fafb;
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
.result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.result-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.result-item.full { grid-column: 1 / -1; }
|
||||
.rl { font-size: 11px; color: var(--text-muted); }
|
||||
.rv { font-size: 13px; color: var(--text); font-weight: 500; }
|
||||
|
||||
.service-list,
|
||||
.rel-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.service-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #f9fafb;
|
||||
border-radius: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.service-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.service-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
.service-port {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
background: #fff;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.service-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--primary);
|
||||
}
|
||||
.target-note {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.service-note {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.service-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #f9fafb;
|
||||
border-radius: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.rel-arrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.rel-host {
|
||||
font-weight: 600;
|
||||
}
|
||||
.rel-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.rel-port {
|
||||
background: #fff;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.rel-note {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.rel-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
</style>
|
||||
460
web/src/views/MachineList.vue
Normal file
460
web/src/views/MachineList.vue
Normal file
@@ -0,0 +1,460 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<!-- Toolbar -->
|
||||
<div class="toolbar">
|
||||
<div class="search-wrap">
|
||||
<el-icon class="search-icon"><Search /></el-icon>
|
||||
<el-input v-model="search" placeholder="搜索主机名" clearable @change="load" />
|
||||
</div>
|
||||
<el-select v-model="osFilter" placeholder="系统类型" clearable @change="load" class="os-select">
|
||||
<el-option label="Linux" value="Linux" />
|
||||
<el-option label="Windows" value="Windows" />
|
||||
<el-option label="macOS" value="macOS" />
|
||||
<el-option label="Other" value="Other" />
|
||||
</el-select>
|
||||
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="openEdit()">添加机器</el-button>
|
||||
<el-button v-if="isAdmin" type="success" @click="handleExport">导出数据</el-button>
|
||||
<el-button v-if="isAdmin" type="warning" @click="handleImportClick">导入数据</el-button>
|
||||
<input ref="importFileRef" type="file" accept=".json" style="display:none" @change="handleImportFile">
|
||||
</div>
|
||||
|
||||
<!-- Cards -->
|
||||
<div class="cards-grid">
|
||||
<div v-for="m in machines" :key="m.id" class="server-card" :class="{ 'guest-card': !isAdmin }" @click="isAdmin && goDetail(m.id)">
|
||||
<div class="card-header">
|
||||
<div class="title-row">
|
||||
<span class="status-dot" :class="m.is_online ? 'online' : 'offline'"></span>
|
||||
<span class="hostname">{{ m.hostname }}</span>
|
||||
<el-tag v-if="m.service_count" size="small" effect="plain" class="svc-tag" round>服务 {{ m.service_count }}</el-tag>
|
||||
</div>
|
||||
<div class="meta-row">
|
||||
<span v-if="isAdmin" class="meta-ip">{{ m.ip }}</span>
|
||||
<span class="meta-item">{{ m.os_type }}</span>
|
||||
<span v-if="m.os_version" class="meta-item">{{ m.os_version }}</span>
|
||||
<span v-if="m.uptime" class="meta-uptime">{{ m.uptime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="m.cpu_info || m.memory_info || m.disk_info" class="stats-row">
|
||||
<div v-if="m.cpu_info" class="stat-pill">
|
||||
<div class="pill-icon cpu">C</div>
|
||||
<div class="pill-body">
|
||||
<div class="pill-label">CPU</div>
|
||||
<div class="pill-bar"><div class="pill-fill" :style="{ width: extractPercent(m.cpu_info) + '%' }"></div></div>
|
||||
</div>
|
||||
<div class="pill-value">{{ extractPercent(m.cpu_info) }}%</div>
|
||||
</div>
|
||||
<div v-if="m.memory_info" class="stat-pill">
|
||||
<div class="pill-icon mem">M</div>
|
||||
<div class="pill-body">
|
||||
<div class="pill-label">RAM</div>
|
||||
<div class="pill-bar"><div class="pill-fill" :style="{ width: extractPercent(m.memory_info) + '%', background: getMemBarColor(extractPercent(m.memory_info)) }"></div></div>
|
||||
</div>
|
||||
<div class="pill-value">{{ extractDetail(m.memory_info) }}</div>
|
||||
</div>
|
||||
<div v-if="getMainDisk(m.disk_info)" class="stat-pill">
|
||||
<div class="pill-icon disk">D</div>
|
||||
<div class="pill-body">
|
||||
<div class="pill-label">DISK</div>
|
||||
<div class="pill-bar"><div class="pill-fill" :style="{ width: getMainDisk(m.disk_info).percent + '%', background: getDiskBarColor(getMainDisk(m.disk_info).percent) }"></div></div>
|
||||
</div>
|
||||
<div class="pill-value">{{ getMainDisk(m.disk_info).detail }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="m.listen_ports" class="ports-row">
|
||||
<el-tag v-for="p in m.listen_ports.split(',').slice(0,6)" :key="p" size="small" effect="plain" round class="port-tag">
|
||||
{{ p.trim() }}
|
||||
</el-tag>
|
||||
<span v-if="m.listen_ports.split(',').length > 6" class="more-ports">+{{ m.listen_ports.split(',').length - 6 }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="m.ssh_synced_at" class="sync-time">
|
||||
同步于 {{ formatTime(m.ssh_synced_at) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="!machines.length" description="暂无机器" />
|
||||
|
||||
<!-- Edit Dialog -->
|
||||
<el-dialog v-model="dialogVisible" :title="editing.id ? '编辑机器' : '添加机器'" width="480px" class="modern-dialog" destroy-on-close>
|
||||
<el-form :model="editing" label-width="90px">
|
||||
<el-form-item label="主机名" required>
|
||||
<el-input v-model="editing.hostname" placeholder="如 web-server-01" />
|
||||
</el-form-item>
|
||||
<el-form-item label="IP" required>
|
||||
<el-input v-model="editing.ip" placeholder="192.168.1.100" />
|
||||
</el-form-item>
|
||||
<el-form-item label="MAC">
|
||||
<el-input v-model="editing.mac" placeholder="AA:BB:CC:DD:EE:FF" />
|
||||
</el-form-item>
|
||||
<el-form-item label="系统" required>
|
||||
<el-select v-model="editing.os_type" style="width:100%">
|
||||
<el-option label="Linux" value="Linux" />
|
||||
<el-option label="Windows" value="Windows" />
|
||||
<el-option label="macOS" value="macOS" />
|
||||
<el-option label="Other" value="Other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="系统版本">
|
||||
<el-input v-model="editing.os_version" placeholder="Ubuntu 22.04" />
|
||||
</el-form-item>
|
||||
<el-divider content-position="left">SSH 配置</el-divider>
|
||||
<el-form-item label="SSH 端口">
|
||||
<el-input-number v-model="editing.ssh_port" :min="1" :max="65535" style="width:100%" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SSH 用户">
|
||||
<el-input v-model="editing.ssh_username" placeholder="root" />
|
||||
</el-form-item>
|
||||
<el-form-item label="SSH 密码">
|
||||
<el-input v-model="editing.ssh_password" type="password" show-password placeholder="输入则更新密码,留空保持不变" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="editing.notes" type="textarea" :rows="3" placeholder="记录用途、负责人等信息" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveMachine">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Plus, Search } from '@element-plus/icons-vue'
|
||||
import { fetchMachines, createMachine, updateMachine, checkAuth, uiRefreshInterval, exportData, importData } from '@/api'
|
||||
import { getAuth } from '@/router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const router = useRouter()
|
||||
const isAdmin = getAuth().is_admin
|
||||
const machines = ref([])
|
||||
const search = ref('')
|
||||
const osFilter = ref('')
|
||||
const dialogVisible = ref(false)
|
||||
const editing = ref({ hostname: '', ip: '', mac: '', os_type: 'Linux', os_version: '', notes: '', ssh_port: 22, ssh_username: '', ssh_password: '' })
|
||||
const importFileRef = ref(null)
|
||||
let timer = null
|
||||
|
||||
onMounted(async () => {
|
||||
await load()
|
||||
await checkAuth()
|
||||
const interval = uiRefreshInterval || 10000
|
||||
if (interval > 0) {
|
||||
timer = setInterval(load, interval)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) clearInterval(timer)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
const res = await fetchMachines({ search: search.value, os_type: osFilter.value })
|
||||
machines.value = res.data
|
||||
}
|
||||
|
||||
function goDetail(id) {
|
||||
router.push(`/machines/${id}`)
|
||||
}
|
||||
|
||||
function openEdit(item) {
|
||||
editing.value = item
|
||||
? { ...item, ssh_port: item.ssh_port || 22, ssh_username: item.ssh_username || '', ssh_password: '' }
|
||||
: { hostname: '', ip: '', mac: '', os_type: 'Linux', os_version: '', notes: '', ssh_port: 22, ssh_username: '', ssh_password: '' }
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveMachine() {
|
||||
const data = { ...editing.value }
|
||||
if (!data.hostname || !data.ip || !data.os_type) {
|
||||
ElMessage.warning('请填写必填项')
|
||||
return
|
||||
}
|
||||
if (data.id) {
|
||||
await updateMachine(data.id, data)
|
||||
} else {
|
||||
await createMachine(data)
|
||||
}
|
||||
ElMessage.success('保存成功')
|
||||
dialogVisible.value = false
|
||||
load()
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const res = await exportData()
|
||||
const blob = new Blob([res.data])
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `lan-manager-backup-${new Date().toISOString().slice(0,10)}.json`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
window.URL.revokeObjectURL(url)
|
||||
ElMessage.success('导出成功')
|
||||
} catch (e) {
|
||||
ElMessage.error('导出失败')
|
||||
}
|
||||
}
|
||||
|
||||
function handleImportClick() {
|
||||
importFileRef.value?.click()
|
||||
}
|
||||
|
||||
async function handleImportFile(e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('mode', 'overwrite')
|
||||
try {
|
||||
await importData(formData)
|
||||
ElMessage.success('导入成功,页面即将刷新')
|
||||
setTimeout(() => location.reload(), 800)
|
||||
} catch (err) {
|
||||
ElMessage.error('导入失败')
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
function extractPercent(str) {
|
||||
if (!str) return 0
|
||||
const m = str.match(/\((\d+)%\)/) || str.match(/(\d+(?:\.\d+)?)%/)
|
||||
if (m) {
|
||||
const v = parseFloat(m[1])
|
||||
return isNaN(v) ? 0 : Math.min(100, Math.max(0, Math.round(v)))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function extractDetail(str) {
|
||||
if (!str) return ''
|
||||
const cpuCore = str.match(/\((\d+)\s*cores\)/)
|
||||
if (cpuCore) return cpuCore[1] + ' cores'
|
||||
let s = str.replace(/\s*\(\d+(?:\.\d+)?%\)\s*/g, '').trim()
|
||||
if (s.includes(',')) s = s.split(',')[0].trim()
|
||||
s = s.replace(/^\/\s+/, '').trim()
|
||||
if (/^\d+\.?\d*\/\d+\.?\d*$/.test(s)) s += 'G'
|
||||
return s
|
||||
}
|
||||
|
||||
function getMemBarColor(v) {
|
||||
if (v >= 90) return '#f87171'
|
||||
if (v >= 70) return '#fbbf24'
|
||||
return '#34d399'
|
||||
}
|
||||
function getDiskBarColor(v) {
|
||||
if (v >= 90) return '#f87171'
|
||||
if (v >= 80) return '#fbbf24'
|
||||
return '#60a5fa'
|
||||
}
|
||||
|
||||
function parseDisks(str) {
|
||||
if (!str) return []
|
||||
const disks = []
|
||||
const regex = /(\S+)\s+([\d\.]+[KMGT]?\/[\d\.]+[KMGT]?)\s+\((\d+)%\)/g
|
||||
let m
|
||||
while ((m = regex.exec(str)) !== null) {
|
||||
disks.push({ mount: m[1], detail: m[2], percent: parseInt(m[3], 10) })
|
||||
}
|
||||
if (disks.length > 0) {
|
||||
return disks
|
||||
}
|
||||
// fallback: single disk old format
|
||||
const pct = extractPercent(str)
|
||||
const det = extractDetail(str)
|
||||
if (det) {
|
||||
return [{ mount: '', detail: det, percent: pct }]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function getMainDisk(str) {
|
||||
const disks = parseDisks(str)
|
||||
const root = disks.find(d => d.mount === '/')
|
||||
return root || null
|
||||
}
|
||||
|
||||
function formatTime(t) {
|
||||
if (!t) return '-'
|
||||
const d = new Date(t)
|
||||
if (isNaN(d.getTime())) return t
|
||||
const pad = n => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.search-wrap {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
max-width: 360px;
|
||||
}
|
||||
.search-wrap :deep(.el-input__wrapper) {
|
||||
padding-left: 34px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 0 1px var(--border) inset;
|
||||
}
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
z-index: 1;
|
||||
}
|
||||
.os-select :deep(.el-input__wrapper) {
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 0 1px var(--border) inset;
|
||||
}
|
||||
|
||||
.cards-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.cards-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.server-card {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
transition: transform .12s ease, box-shadow .2s ease;
|
||||
}
|
||||
.server-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(0,0,0,0.06);
|
||||
}
|
||||
.server-card.guest-card {
|
||||
cursor: default;
|
||||
}
|
||||
.server-card.guest-card:hover {
|
||||
transform: none;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
}
|
||||
.status-dot.online { background: var(--success); box-shadow: 0 0 0 3px rgba(34,197,94,.18); }
|
||||
.status-dot.offline { background: var(--danger); box-shadow: 0 0 0 3px rgba(239,68,68,.18); }
|
||||
.hostname {
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
color: var(--text);
|
||||
}
|
||||
.svc-tag {
|
||||
font-size: 11px;
|
||||
height: 18px;
|
||||
padding: 0 8px;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.meta-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.meta-ip {
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
background: #f3f4f6;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.meta-item + .meta-item::before,
|
||||
.meta-uptime::before {
|
||||
content: '·';
|
||||
margin: 0 6px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.meta-uptime { color: var(--success); }
|
||||
|
||||
.stats-row {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
.stat-pill {
|
||||
background: linear-gradient(180deg, rgba(17,24,39,0.72), rgba(17,24,39,0.62));
|
||||
border-radius: 12px;
|
||||
padding: 10px;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pill-icon {
|
||||
width: 24px; height: 24px; border-radius: 6px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 10px; font-weight: 800;
|
||||
background: rgba(255,255,255,0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.pill-icon.cpu { color: #a5f3fc; }
|
||||
.pill-icon.mem { color: #bbf7d0; }
|
||||
.pill-icon.disk { color: #bfdbfe; }
|
||||
.pill-body {
|
||||
flex: 1; display: flex; flex-direction: column; gap: 4px; min-width: 0;
|
||||
}
|
||||
.pill-label { font-size: 10px; color: rgba(255,255,255,0.85); font-weight: 600; }
|
||||
.pill-bar { height: 3px; background: rgba(255,255,255,0.15); border-radius: 2px; overflow: hidden; }
|
||||
.pill-fill { height: 100%; border-radius: 2px; background: rgba(255,255,255,0.9); transition: width .3s ease; }
|
||||
.pill-value { font-size: 11px; font-weight: 700; color: #fff; white-space: nowrap; }
|
||||
|
||||
.ports-row {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.port-tag {
|
||||
font-size: 11px;
|
||||
height: 20px;
|
||||
padding: 0 8px;
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border);
|
||||
}
|
||||
.more-ports { font-size: 11px; color: var(--text-muted); line-height: 20px; }
|
||||
|
||||
.sync-time {
|
||||
margin-top: 10px;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
265
web/src/views/Topology.vue
Normal file
265
web/src/views/Topology.vue
Normal file
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<div class="page-title">拓扑图</div>
|
||||
<div class="page-subtitle">展示机器之间的服务指向和关联关系</div>
|
||||
</div>
|
||||
<el-button v-if="isAdmin" type="primary" :icon="Plus" @click="$router.push('/machines')">添加机器</el-button>
|
||||
</div>
|
||||
|
||||
<div class="topo-card">
|
||||
<div id="topo" class="topo-canvas"></div>
|
||||
<div class="legend">
|
||||
<div class="legend-item"><span class="dot online"></span> 在线</div>
|
||||
<div class="legend-item"><span class="dot offline"></span> 离线</div>
|
||||
<div class="legend-item"><span class="line service"></span> 服务指向</div>
|
||||
<div class="legend-item"><span class="line relation"></span> 关联关系</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Graph } from '@antv/g6'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import { fetchMachines, fetchAllServices as fetchServicesAll, fetchRelationships } from '@/api'
|
||||
import { getAuth } from '@/router'
|
||||
|
||||
const isAdmin = getAuth().is_admin
|
||||
let graph = null
|
||||
|
||||
onMounted(async () => {
|
||||
const [machinesRes, servicesRes, relsRes] = await Promise.all([
|
||||
fetchMachines(),
|
||||
fetchServicesAll(),
|
||||
fetchRelationships()
|
||||
])
|
||||
|
||||
const machines = machinesRes.data
|
||||
const services = servicesRes.data
|
||||
const relationships = relsRes.data
|
||||
|
||||
const machineMap = {}
|
||||
machines.forEach(m => machineMap[m.id] = m)
|
||||
|
||||
const nodes = machines.map(m => ({
|
||||
id: String(m.id),
|
||||
label: m.hostname,
|
||||
online: m.is_online,
|
||||
os: m.os_type,
|
||||
style: {
|
||||
fill: '#ffffff',
|
||||
stroke: osColor(m.os_type),
|
||||
lineWidth: 2,
|
||||
radius: 8,
|
||||
shadowColor: 'rgba(0,0,0,0.06)',
|
||||
shadowBlur: 8,
|
||||
shadowOffsetY: 2
|
||||
},
|
||||
labelCfg: {
|
||||
style: { fill: '#111827', fontSize: 13, fontWeight: 600 }
|
||||
},
|
||||
badgeCfg: {
|
||||
position: 'topRight',
|
||||
style: {
|
||||
fill: m.is_online ? '#22c55e' : '#ef4444',
|
||||
stroke: '#fff',
|
||||
lineWidth: 2
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
const edges = []
|
||||
|
||||
relationships.forEach(r => {
|
||||
if (!machineMap[r.source_machine_id] || !machineMap[r.target_machine_id]) return
|
||||
edges.push({
|
||||
id: `rel-${r.id}`,
|
||||
source: String(r.source_machine_id),
|
||||
target: String(r.target_machine_id),
|
||||
label: relLabel(r),
|
||||
style: {
|
||||
stroke: '#f97316',
|
||||
lineDash: [4, 2],
|
||||
lineWidth: 1.5,
|
||||
endArrow: { path: 'M 0,0 L 8,4 L 8,-4 Z', fill: '#f97316' }
|
||||
},
|
||||
labelCfg: {
|
||||
style: { fill: '#f97316', fontSize: 11, fontWeight: 500, background: { fill: '#fff', padding: [2, 4], radius: 4 } }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
services.forEach(s => {
|
||||
if (s.target_machine_id && machineMap[s.target_machine_id]) {
|
||||
edges.push({
|
||||
id: `svc-${s.id}`,
|
||||
source: String(s.machine_id),
|
||||
target: String(s.target_machine_id),
|
||||
label: s.name,
|
||||
style: {
|
||||
stroke: '#3b82f6',
|
||||
lineWidth: 2,
|
||||
endArrow: { path: 'M 0,0 L 8,4 L 8,-4 Z', fill: '#3b82f6' }
|
||||
},
|
||||
labelCfg: {
|
||||
style: { fill: '#3b82f6', fontSize: 11, fontWeight: 500, background: { fill: '#fff', padding: [2, 4], radius: 4 } }
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
graph = new Graph({
|
||||
container: 'topo',
|
||||
width: document.getElementById('topo').clientWidth,
|
||||
height: document.getElementById('topo').clientHeight,
|
||||
layout: {
|
||||
type: 'force',
|
||||
preventOverlap: true,
|
||||
linkDistance: 140,
|
||||
nodeStrength: -80,
|
||||
edgeStrength: 0.2
|
||||
},
|
||||
defaultNode: {
|
||||
type: 'rect',
|
||||
size: [110, 40]
|
||||
},
|
||||
defaultEdge: {
|
||||
type: 'line',
|
||||
style: { endArrow: true }
|
||||
},
|
||||
modes: { default: ['drag-node', 'drag-canvas', 'zoom-canvas'] },
|
||||
fitView: true,
|
||||
fitViewPadding: 20
|
||||
})
|
||||
|
||||
graph.data({ nodes, edges })
|
||||
graph.render()
|
||||
|
||||
graph.on('node:click', e => {
|
||||
const nodeId = e.item.getModel().id
|
||||
highlightNode(nodeId)
|
||||
})
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const container = document.getElementById('topo')
|
||||
if (container && graph) {
|
||||
graph.changeSize(container.clientWidth, container.clientHeight)
|
||||
graph.fitView()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function highlightNode(nodeId) {
|
||||
if (!graph) return
|
||||
const edges = graph.getEdges()
|
||||
edges.forEach(edge => {
|
||||
const model = edge.getModel()
|
||||
if (model.source === nodeId || model.target === nodeId) {
|
||||
graph.setItemState(edge, 'active', true)
|
||||
edge.update({ style: { opacity: 1 } })
|
||||
} else {
|
||||
graph.setItemState(edge, 'inactive', true)
|
||||
edge.update({ style: { opacity: 0.15 } })
|
||||
}
|
||||
})
|
||||
setTimeout(() => {
|
||||
edges.forEach(edge => {
|
||||
graph.clearItemStates(edge)
|
||||
edge.update({ style: { opacity: 1 } })
|
||||
})
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
function osColor(os) {
|
||||
switch (os) {
|
||||
case 'Linux': return '#3b82f6'
|
||||
case 'Windows': return '#06b6d4'
|
||||
case 'macOS': return '#a855f7'
|
||||
default: return '#9ca3af'
|
||||
}
|
||||
}
|
||||
|
||||
function relLabel(r) {
|
||||
const typeMap = { port_forward: '端口转发', dependency: '依赖', primary_secondary: '主从', custom: '自定义' }
|
||||
const t = typeMap[r.relation_type] || r.relation_type
|
||||
return r.source_port ? `${t} (${r.source_port})` : t
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.page-title {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.page-subtitle {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.topo-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
padding: 12px;
|
||||
position: relative;
|
||||
height: calc(100vh - 220px);
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.topo-canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.legend {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
bottom: 16px;
|
||||
background: rgba(255,255,255,0.95);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.05);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.dot.online { background: #22c55e; }
|
||||
.dot.offline { background: #ef4444; }
|
||||
.line {
|
||||
width: 16px;
|
||||
height: 2px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
.line.service { background: #3b82f6; }
|
||||
.line.relation {
|
||||
background: repeating-linear-gradient(90deg, #f97316, #f97316 4px, transparent 4px, transparent 6px);
|
||||
height: 2px;
|
||||
}
|
||||
</style>
|
||||
24
web/vite.config.js
Normal file
24
web/vite.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8080',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user