## 後端(Phase 1) 新增 flash 模組(從 edge-ai-platform 搬入): - server/internal/flash/service.go:StartFlash + 模型相容性檢查 + 晶片 NEF 解析 - server/internal/flash/progress.go:Flash 進度追蹤器 - server/internal/api/ws/flash_ws.go:WebSocket 推送 flash 進度 - device_handler.go:新增 FlashDevice method + flashSvc 欄位 - router.go:新增 POST /api/devices/:id/flash + WS /ws/devices/:id/flash-progress - main.go:初始化 flash.NewService 並傳入 router 推論/攝影機/MJPEG/inference WebSocket 之前 M1 已搬好,不需改動。 Python bridge (kneron_bridge.py) 與 edge-ai-platform 完全相同,不需改動。 ## 前端 store + hooks(Phase 2) - stores/flash-store.ts(新):Zustand store — startFlash / updateProgress / retryFlash / reset - hooks/use-flash-progress.ts(新):WebSocket hook 接收 flash 進度 inference-store / camera-store / inference types / use-inference-stream / use-websocket 之前 M1 已搬好,不需改動。 ## 前端 UI 元件(Phase 3) - components/devices/flash-dialog.tsx(新):模型載入對話框 + 硬體相容性檢查 - components/devices/flash-progress.tsx(新):Flash 進度條 + 錯誤重試 camera-inference-view / camera-feed / camera-overlay / source-selector / inference-panel / performance-metrics / classification-result / confidence-slider / video-progress / batch-image-thumbnails 之前 M1 已搬好。 ## 前端頁面整合(Phase 4) - workspace/page.tsx:繁中硬編碼、顯示已載入模型名稱 - workspace/[deviceId]/workspace-client.tsx:加入 FlashDialog 按鈕 + 繁中硬編碼 - devices/[id]/device-detail-client.tsx:加入 FlashDialog + 「進入工作區」按鈕(模型已載入才顯示) - device-card.tsx:已連線 + 模型已載入時顯示「工作區」快捷按鈕 ## 使用者操作流程 裝置列表 → 連線 → 管理 → 載入模型 → 進入工作區 → 選攝影機/圖片/影片 → 開始推論 → 看 bounding box / FPS / latency 或:裝置列表 → 工作區(已有模型)→ 直接推論 ## 不搬的東西 - cluster/* 全部不搬(已砍 cluster 功能) - relay / tunnel 相關不搬 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
209 lines
6.6 KiB
Go
209 lines
6.6 KiB
Go
package api
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"visiona-local/server/internal/api/handlers"
|
|
"visiona-local/server/internal/api/ws"
|
|
"visiona-local/server/internal/camera"
|
|
"visiona-local/server/internal/device"
|
|
"visiona-local/server/internal/flash"
|
|
"visiona-local/server/internal/inference"
|
|
"visiona-local/server/internal/model"
|
|
"visiona-local/server/pkg/logger"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
func NewRouter(
|
|
modelRepo *model.Repository,
|
|
modelStore *model.ModelStore,
|
|
deviceMgr *device.Manager,
|
|
cameraMgr *camera.Manager,
|
|
flashSvc *flash.Service,
|
|
inferenceSvc *inference.Service,
|
|
wsHub *ws.Hub,
|
|
staticFS http.FileSystem,
|
|
logBroadcaster *logger.Broadcaster,
|
|
systemHandler *handlers.SystemHandler,
|
|
) *gin.Engine {
|
|
// Use gin.New() instead of gin.Default() to replace the default logger
|
|
// with one that also pushes to the WebSocket broadcaster.
|
|
r := gin.New()
|
|
r.Use(gin.Recovery())
|
|
r.Use(broadcasterLogger(logBroadcaster))
|
|
r.Use(CORSMiddleware())
|
|
|
|
modelHandler := handlers.NewModelHandler(modelRepo)
|
|
modelUploadHandler := handlers.NewModelUploadHandler(modelRepo, modelStore)
|
|
deviceHandler := handlers.NewDeviceHandler(deviceMgr, flashSvc, inferenceSvc, wsHub)
|
|
cameraHandler := handlers.NewCameraHandler(cameraMgr, deviceMgr, inferenceSvc, wsHub)
|
|
|
|
api := r.Group("/api")
|
|
{
|
|
// System
|
|
api.GET("/system/health", systemHandler.HealthCheck)
|
|
api.GET("/system/info", systemHandler.Info)
|
|
api.GET("/system/metrics", systemHandler.Metrics)
|
|
api.GET("/system/deps", systemHandler.Deps)
|
|
api.POST("/system/restart", systemHandler.Restart)
|
|
api.POST("/system/install-driver", systemHandler.InstallDriver)
|
|
|
|
// Models
|
|
api.GET("/models", modelHandler.ListModels)
|
|
api.GET("/models/:id", modelHandler.GetModel)
|
|
api.POST("/models/upload", modelUploadHandler.UploadModel)
|
|
api.DELETE("/models/:id", modelUploadHandler.DeleteModel)
|
|
|
|
// Devices
|
|
api.GET("/devices", deviceHandler.ListDevices)
|
|
api.POST("/devices/scan", deviceHandler.ScanDevices)
|
|
api.GET("/devices/:id", deviceHandler.GetDevice)
|
|
api.POST("/devices/:id/connect", deviceHandler.ConnectDevice)
|
|
api.POST("/devices/:id/disconnect", deviceHandler.DisconnectDevice)
|
|
api.POST("/devices/:id/flash", deviceHandler.FlashDevice)
|
|
api.POST("/devices/:id/inference/start", deviceHandler.StartInference)
|
|
api.POST("/devices/:id/inference/stop", deviceHandler.StopInference)
|
|
|
|
// Camera
|
|
api.GET("/camera/list", cameraHandler.ListCameras)
|
|
api.POST("/camera/start", cameraHandler.StartPipeline)
|
|
api.POST("/camera/stop", cameraHandler.StopPipeline)
|
|
api.GET("/camera/stream", cameraHandler.StreamMJPEG)
|
|
|
|
// Media
|
|
api.POST("/media/upload/image", cameraHandler.UploadImage)
|
|
api.POST("/media/upload/video", cameraHandler.UploadVideo)
|
|
api.POST("/media/upload/batch-images", cameraHandler.UploadBatchImages)
|
|
api.GET("/media/batch-images/:index", cameraHandler.GetBatchImageFrame)
|
|
api.POST("/media/url", cameraHandler.StartFromURL)
|
|
api.POST("/media/seek", cameraHandler.SeekVideo)
|
|
}
|
|
|
|
// WebSocket
|
|
r.GET("/ws/devices/events", ws.DeviceEventsHandler(wsHub, deviceMgr))
|
|
r.GET("/ws/devices/:id/flash-progress", ws.FlashProgressHandler(wsHub))
|
|
r.GET("/ws/devices/:id/inference", ws.InferenceHandler(wsHub, inferenceSvc))
|
|
r.GET("/ws/server-logs", ws.ServerLogsHandler(wsHub, logBroadcaster))
|
|
|
|
// Embedded frontend static file serving (production mode)
|
|
if staticFS != nil {
|
|
fileServer := http.FileServer(staticFS)
|
|
|
|
// Serve Next.js-style static assets
|
|
r.GET("/_next/*filepath", func(c *gin.Context) {
|
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
|
})
|
|
r.GET("/favicon.ico", func(c *gin.Context) {
|
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
|
})
|
|
|
|
// SPA fallback for all other routes (client-side routing)
|
|
r.NoRoute(spaFallback(staticFS))
|
|
}
|
|
|
|
return r
|
|
}
|
|
|
|
// broadcasterLogger is a Gin middleware that logs HTTP requests to both
|
|
// stdout (like gin.Logger) and the WebSocket log broadcaster so that
|
|
// request logs are visible in the frontend Settings page.
|
|
func broadcasterLogger(b *logger.Broadcaster) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
start := time.Now()
|
|
path := c.Request.URL.Path
|
|
raw := c.Request.URL.RawQuery
|
|
|
|
c.Next()
|
|
|
|
latency := time.Since(start)
|
|
status := c.Writer.Status()
|
|
method := c.Request.Method
|
|
|
|
if raw != "" {
|
|
path = path + "?" + raw
|
|
}
|
|
|
|
msg := fmt.Sprintf("%3d | %13v | %-7s %s",
|
|
status, latency, method, path)
|
|
|
|
// Write to stdout (original Gin behaviour)
|
|
fmt.Printf("[GIN] %s\n", msg)
|
|
|
|
// Push to broadcaster for WebSocket streaming
|
|
if b != nil {
|
|
level := "INFO"
|
|
if status >= 500 {
|
|
level = "ERROR"
|
|
} else if status >= 400 {
|
|
level = "WARN"
|
|
}
|
|
b.Push(level, fmt.Sprintf("[GIN] %s", msg))
|
|
}
|
|
}
|
|
}
|
|
|
|
// spaFallback tries to serve the exact file from the embedded FS.
|
|
// If the file doesn't exist, it finds the best matching route shell HTML
|
|
// for Next.js static export client-side routing.
|
|
//
|
|
// Next.js static export with generateStaticParams creates:
|
|
// /models/index.html — static page
|
|
// /models/_/index.html — dynamic route shell (placeholder param '_')
|
|
//
|
|
// For a request like /models/yolov5-face-detection:
|
|
// 1. Try exact file → not found
|
|
// 2. Try /models/_/index.html → found → serve it (Next.js CSR picks up real param from URL)
|
|
// 3. Fall back to /index.html (root)
|
|
func spaFallback(staticFS http.FileSystem) gin.HandlerFunc {
|
|
return func(c *gin.Context) {
|
|
path := c.Request.URL.Path
|
|
|
|
// Don't serve index.html for API or WebSocket routes
|
|
if strings.HasPrefix(path, "/api/") || strings.HasPrefix(path, "/ws/") {
|
|
c.Status(http.StatusNotFound)
|
|
return
|
|
}
|
|
|
|
// Try to serve the exact file
|
|
if f, err := staticFS.Open(path); err == nil {
|
|
f.Close()
|
|
http.FileServer(staticFS).ServeHTTP(c.Writer, c.Request)
|
|
return
|
|
}
|
|
|
|
// Try Next.js dynamic route shell: replace last path segment with '_'
|
|
// e.g. /models/yolov5 → /models/_/index.html
|
|
// /devices/kl520-0 → /devices/_/index.html
|
|
// /workspace/kl520-0 → /workspace/_/index.html
|
|
segments := strings.Split(strings.TrimRight(path, "/"), "/")
|
|
if len(segments) >= 2 {
|
|
segments[len(segments)-1] = "_"
|
|
shellPath := strings.Join(segments, "/") + "/index.html"
|
|
if f, err := staticFS.Open(shellPath); err == nil {
|
|
defer f.Close()
|
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
c.Status(http.StatusOK)
|
|
_, _ = io.Copy(c.Writer, f)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Final fallback: root index.html
|
|
index, err := staticFS.Open("/index.html")
|
|
if err != nil {
|
|
c.Status(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
defer index.Close()
|
|
|
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
|
c.Status(http.StatusOK)
|
|
_, _ = io.Copy(c.Writer, index)
|
|
}
|
|
}
|