feat: DevTools 数据库监看面板 + 隧道控制 + 多项 Bug 修复

**DevTools 新增功能 (Tasks 13-14):**
- 首页仪表盘添加数据库实时监看卡片 (5端口状态 + 记忆数)
- 侧边栏新增数据库面板,支持自动 5 秒刷新
- 数据库面板显示 PostgreSQL/Redis/Qdrant/MinIO/NATS 端口状态
- 隧道控制按钮 (启动/停止/重启/查看状态)
- 新增 API 端点: GET /api/database/status, POST /api/tunnel/:action
- 更新 docs/api-reference/ API 文档

**Bug 修复 (Task 15):**
- 修复 pgrep -f 自匹配导致隧道状态误判 (添加 ^ssh 锚点)
  - devtools/src/index.js (dashboard + database/status)
  - scripts/tunnel.sh (is_tunnel_running + show_status)
- 修复数据库面板缺少自动刷新定时器
- 修复侧边栏数据库徽章永远 display:none
- 修复僵尸进程场景下按钮死锁问题

**其他改进:**
- .gitignore 添加 backend/cmd, backend/iot-debug-service/main
- 前端多项改进 (登录/注册/会话/流式动画等)
This commit is contained in:
2026-05-17 11:42:42 +08:00
parent 0757ad26b5
commit 5d0bb96abe
28 changed files with 1723 additions and 218 deletions
+39
View File
@@ -0,0 +1,39 @@
# ========== 构建阶段 ==========
FROM golang:1.23-alpine AS builder
RUN apk add --no-cache git ca-certificates
WORKDIR /app
# 复制 go.mod 并下载依赖(利用 Docker 缓存层)
COPY go.mod ./
RUN go mod download
# 复制源代码
COPY . .
# 编译 (静态链接,适配 Alpine)
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /iot-debug-service ./cmd/main.go
# ========== 运行阶段 ==========
FROM alpine:3.20
RUN apk add --no-cache ca-certificates tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone
WORKDIR /app
# 从构建阶段复制二进制文件
COPY --from=builder /iot-debug-service .
# 非 root 用户
RUN adduser -D -H cyrene
USER cyrene
EXPOSE 8083
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8083/api/v1/health || exit 1
ENTRYPOINT ["./iot-debug-service"]
+366
View File
@@ -0,0 +1,366 @@
package main
import (
"encoding/json"
"fmt"
"log"
"math/rand"
"net/http"
"os"
"strings"
"sync"
"time"
)
// DeviceType 设备类型
type DeviceType string
const (
TypeLight DeviceType = "light"
TypeAC DeviceType = "ac"
TypeCurtain DeviceType = "curtain"
TypeSensor DeviceType = "sensor"
TypeLock DeviceType = "lock"
)
// Device 设备状态
type Device struct {
ID string `json:"id"`
Name string `json:"name"`
Type DeviceType `json:"type"`
Status string `json:"status"` // on/off/closed/open/locked/unlocked
Brightness int `json:"brightness,omitempty"`
Color string `json:"color,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Mode string `json:"mode,omitempty"` // cool/heat/auto
Position int `json:"position,omitempty"` // curtain position 0-100
Value float64 `json:"value,omitempty"` // sensor value
Unit string `json:"unit,omitempty"` // sensor unit
Battery int `json:"battery,omitempty"` // lock battery
LastUpdated string `json:"last_updated"`
History []HistoryEntry `json:"history,omitempty"`
}
// HistoryEntry 设备状态历史
type HistoryEntry struct {
Timestamp string `json:"timestamp"`
Field string `json:"field"`
OldValue string `json:"old_value"`
NewValue string `json:"new_value"`
}
// DeviceStore 设备存储(线程安全)
type DeviceStore struct {
mu sync.RWMutex
devices map[string]*Device
history map[string][]HistoryEntry
}
func NewDeviceStore() *DeviceStore {
ds := &DeviceStore{
devices: make(map[string]*Device),
history: make(map[string][]HistoryEntry),
}
ds.initDevices()
return ds
}
func (ds *DeviceStore) initDevices() {
now := time.Now().UTC().Format(time.RFC3339)
devices := []*Device{
{ID: "light-livingroom", Name: "客厅灯", Type: TypeLight, Status: "on", Brightness: 80, Color: "warm_white", LastUpdated: now},
{ID: "light-bedroom", Name: "卧室灯", Type: TypeLight, Status: "off", Brightness: 0, Color: "warm_white", LastUpdated: now},
{ID: "ac-livingroom", Name: "客厅空调", Type: TypeAC, Status: "on", Temperature: 26, Mode: "cool", LastUpdated: now},
{ID: "ac-bedroom", Name: "卧室空调", Type: TypeAC, Status: "off", Temperature: 24, Mode: "auto", LastUpdated: now},
{ID: "curtain-livingroom", Name: "客厅窗帘", Type: TypeCurtain, Status: "closed", Position: 0, LastUpdated: now},
{ID: "sensor-temperature", Name: "温度传感器", Type: TypeSensor, Value: 25.5, Unit: "celsius", LastUpdated: now},
{ID: "sensor-humidity", Name: "湿度传感器", Type: TypeSensor, Value: 60, Unit: "percent", LastUpdated: now},
{ID: "lock-door", Name: "智能门锁", Type: TypeLock, Status: "locked", Battery: 85, LastUpdated: now},
}
for _, d := range devices {
ds.devices[d.ID] = d
ds.history[d.ID] = make([]HistoryEntry, 0)
}
}
// GetAll 获取所有设备
func (ds *DeviceStore) GetAll() []*Device {
ds.mu.RLock()
defer ds.mu.RUnlock()
result := make([]*Device, 0, len(ds.devices))
for _, d := range ds.devices {
cp := *d
cp.History = nil // 列表不返回历史
result = append(result, &cp)
}
return result
}
// Get 获取单个设备
func (ds *DeviceStore) Get(id string) *Device {
ds.mu.RLock()
defer ds.mu.RUnlock()
d, ok := ds.devices[id]
if !ok {
return nil
}
cp := *d
// 包含最近10条历史(RLock 可重入)
if h, ok := ds.history[id]; ok && len(h) > 0 {
start := 0
if len(h) > 10 {
start = len(h) - 10
}
cp.History = make([]HistoryEntry, len(h)-start)
copy(cp.History, h[start:])
} else {
cp.History = []HistoryEntry{}
}
return &cp
}
// GetHistory 获取设备状态历史(最近10条)
func (ds *DeviceStore) GetHistory(id string) []HistoryEntry {
ds.mu.RLock()
defer ds.mu.RUnlock()
h, ok := ds.history[id]
if !ok || len(h) == 0 {
return []HistoryEntry{}
}
start := 0
if len(h) > 10 {
start = len(h) - 10
}
result := make([]HistoryEntry, len(h[start:]))
copy(result, h[start:])
return result
}
// addHistory 添加历史记录
func (ds *DeviceStore) addHistory(id, field, oldVal, newVal string) {
entry := HistoryEntry{
Timestamp: time.Now().UTC().Format(time.RFC3339),
Field: field,
OldValue: oldVal,
NewValue: newVal,
}
ds.history[id] = append(ds.history[id], entry)
}
// Toggle 切换设备开关状态
func (ds *DeviceStore) Toggle(id string) (*Device, error) {
ds.mu.Lock()
defer ds.mu.Unlock()
d, ok := ds.devices[id]
if !ok {
return nil, fmt.Errorf("设备 %s 不存在", id)
}
switch d.Type {
case TypeLight:
oldStatus := d.Status
if d.Status == "on" {
d.Status = "off"
d.Brightness = 0
} else {
d.Status = "on"
d.Brightness = 80
}
d.LastUpdated = time.Now().UTC().Format(time.RFC3339)
ds.addHistory(id, "status", oldStatus, d.Status)
case TypeAC:
oldStatus := d.Status
if d.Status == "on" {
d.Status = "off"
} else {
d.Status = "on"
}
d.LastUpdated = time.Now().UTC().Format(time.RFC3339)
ds.addHistory(id, "status", oldStatus, d.Status)
case TypeCurtain:
oldStatus := d.Status
if d.Status == "closed" {
d.Status = "open"
d.Position = 100
} else {
d.Status = "closed"
d.Position = 0
}
d.LastUpdated = time.Now().UTC().Format(time.RFC3339)
ds.addHistory(id, "status", oldStatus, d.Status)
case TypeLock:
oldStatus := d.Status
if d.Status == "locked" {
d.Status = "unlocked"
} else {
d.Status = "locked"
}
d.LastUpdated = time.Now().UTC().Format(time.RFC3339)
ds.addHistory(id, "status", oldStatus, d.Status)
default:
return nil, fmt.Errorf("设备类型 %s 不支持切换", d.Type)
}
cp := *d
cp.History = ds.GetHistory(id)
return &cp, nil
}
// SimulateFluctuation 模拟传感器随机波动
func (ds *DeviceStore) SimulateFluctuation() {
ds.mu.Lock()
defer ds.mu.Unlock()
now := time.Now().UTC().Format(time.RFC3339)
// 温度传感器: ±0.2°C
if t, ok := ds.devices["sensor-temperature"]; ok {
oldVal := t.Value
t.Value += (rand.Float64()*0.4 - 0.2)
t.Value = float64(int(t.Value*10)) / 10 // 保留一位小数
t.LastUpdated = now
ds.addHistory("sensor-temperature", "value", fmt.Sprintf("%.1f", oldVal), fmt.Sprintf("%.1f", t.Value))
}
// 湿度传感器: ±1%
if h, ok := ds.devices["sensor-humidity"]; ok {
oldVal := h.Value
h.Value += float64(rand.Intn(3) - 1) // -1, 0, +1
if h.Value < 0 {
h.Value = 0
}
if h.Value > 100 {
h.Value = 100
}
h.LastUpdated = now
ds.addHistory("sensor-humidity", "value", fmt.Sprintf("%.0f", oldVal), fmt.Sprintf("%.0f", h.Value))
}
}
func main() {
port := getEnv("IOT_DEBUG_PORT", "8083")
store := NewDeviceStore()
// 启动传感器波动模拟(每30秒)
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for range ticker.C {
store.SimulateFluctuation()
}
}()
mux := http.NewServeMux()
// GET /api/v1/devices - 列出所有设备
mux.HandleFunc("/api/v1/devices", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
devices := store.GetAll()
writeJSON(w, http.StatusOK, map[string]interface{}{
"devices": devices,
"total": len(devices),
})
})
// GET /api/v1/devices/{id} - 获取单个设备
// POST /api/v1/devices/{id}/toggle - 切换设备
mux.HandleFunc("/api/v1/devices/", func(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/api/v1/devices/")
parts := strings.Split(path, "/")
if len(parts) == 0 || parts[0] == "" {
http.Error(w, "缺少设备ID", http.StatusBadRequest)
return
}
deviceID := parts[0]
// POST /api/v1/devices/{id}/toggle
if len(parts) == 2 && parts[1] == "toggle" {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
device, err := store.Toggle(deviceID)
if err != nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"device": device,
"action": "toggled",
})
return
}
// GET /api/v1/devices/{id}/history
if len(parts) == 2 && parts[1] == "history" {
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
history := store.GetHistory(deviceID)
writeJSON(w, http.StatusOK, map[string]interface{}{
"device_id": deviceID,
"history": history,
})
return
}
// GET /api/v1/devices/{id}
if r.Method != http.MethodGet {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
device := store.Get(deviceID)
if device == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": fmt.Sprintf("设备 %s 不存在", deviceID)})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"device": device,
})
})
// 健康检查
mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{
"status": "ok",
"service": "iot-debug-service",
})
})
log.Printf("🔌 IoT 调试服务启动在端口 %s", port)
log.Printf(" 模拟设备数: %d", len(store.GetAll()))
if err := http.ListenAndServe(":"+port, mux); err != nil {
log.Fatalf("服务启动失败: %v", err)
}
}
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data)
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+3
View File
@@ -0,0 +1,3 @@
module cyrene/iot-debug-service
go 1.21