Initial commit: LAN Manager with Go backend and Vue frontend

This commit is contained in:
shirainbown
2026-04-15 00:52:25 +08:00
commit d28aae585f
44 changed files with 7349 additions and 0 deletions

52
.gitignore vendored Normal file
View 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
View 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
View 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 Ping3 秒内无响应视为离线
- **磁盘显示策略**:机器列表卡片仅展示 `/` 根分区占用;详情页展示所有真实磁盘分区

Binary file not shown.

View 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
```
**方式 BNginx 反向代理(推荐)**
```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 配置信息。
### 方式 AWeb 界面(管理员登录后)
进入 **机器列表** 页面,点击右上角的:
- **导出数据**:下载 `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`。

View 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 ""

View 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

View 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

View 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
View 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. 导入/导出 APIJSON 格式)
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
View 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 命令回退 | 定时 goroutine1 分钟间隔 |
| 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
View 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
View 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=

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"})
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

24
web/package.json Normal file
View 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
View 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
View 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' },
})

View 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
View 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
View 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
View 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
View 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>

View 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>

View 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
View 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
View 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',
},
})