jim800121chen 8cd5751ce3 feat(local-tool): M8 重構 — Wails 控制台 + 瀏覽器 Web UI(R5 決策)
依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。

程式碼變動
  - M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
    Makefile vendor / installer / bootstrap / CI workflow,-555 行)
  - M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
    VISIONA_MOCK 環境變數,-528 行)
  - M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
    LGPL binary,macOS 自 build minimal decoder-only 進 git
    (vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
  - M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
    preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
    notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
  - M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
    stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
  - M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
    state 視覺、log panel、startup progress panel、Stage 6 manual CTA
    pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
  - M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
  - M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
    wsEverConnected 容錯 + Page Visibility)
  - M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
    ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
  - MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
    (/ws/system endpoint + notifyShutdownImminent helper)
  - M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)

品質
  - ~105+ 新 unit test + race detector (-count=2) 全綠
  - 10 個 milestone 全部通過 Reviewer 審查
  - 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
    收錄在 .autoflow/

交付前待處理(M8-10)
  - 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
  - 三平台 end-to-end build 驗證

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:57:54 +08:00

206 lines
5.9 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
// log_buffer.go — M8-4Wails 控制台 log ring buffer
//
// 負責:
// 1. 2000 行 thread-safe ring buffer
// 2. LogLine 結構(含 level 解析)
// 3. Snapshot取最後 n 行(複製,不回傳 view
// 4. Rate limit1 秒 > 200 條時退化為「只寫檔不 emit」
//
// TDD ground truth
// - .autoflow/04-architecture/v2/control-panel.md §4.3§4.5
// - .autoflow/04-architecture/v2/server-lifecycle.md §4stdout/stderr pipe 捕捉)
//
// 本檔只做「資料結構」+「level 解析」+「rate limit 判定」。
// 實際的 logPump goroutine 與 Wails event emit 放在 server_control.go。
import (
"strings"
"sync"
"sync/atomic"
"time"
)
const (
// logBufferCap 是 ring buffer 行數上限。TDD 定版 2000。
logBufferCap = 2000
// logRateLimitWindow 是 rate limit 的視窗長度。
logRateLimitWindow = 1 * time.Second
// logRateLimitBurst 是視窗內允許 emit 的最大行數。超過就只寫檔不 emit。
logRateLimitBurst = 200
)
// LogLine 是 ring buffer 中的單一條目。
//
// 對應 TDD v2/control-panel.md §4.3 的 LogLine struct。
type LogLine struct {
// Ts 為 Unix 毫秒(方便前端 JS 直接 new Date(ts))。
Ts int64 `json:"ts"`
// Stream 為來源stdout / stderr / wails
Stream string `json:"stream"`
// Line 為原始那一行文字。
Line string `json:"line"`
// Level 為解析後的等級info / warn / error / debug解析不到則為 ""。
Level string `json:"level,omitempty"`
}
// LogBuffer 是 thread-safe 的 ring buffer。
type LogBuffer struct {
mu sync.Mutex
lines [logBufferCap]LogLine
head int // 下一筆寫入位置
size int // 目前已存筆數(最多 logBufferCap
// dropped 紀錄被覆寫的最舊條目總數(給 debug / 未來統計用)。
dropped uint64
// rateLimit 相關:用原子計數維持最小鎖定範圍
rlWindowStart atomic.Int64 // 視窗起點Unix nano
rlCount atomic.Int64 // 視窗內已 emit 的行數
}
// NewLogBuffer 建立一個新的 ring buffer。
func NewLogBuffer() *LogBuffer {
return &LogBuffer{}
}
// Append 在 buffer 最末端追加一行。buffer 滿時會覆寫最舊行。
//
// 這個方法只動 ring bufferrate limit 判定是獨立的 ShouldEmit。
func (b *LogBuffer) Append(l LogLine) {
b.mu.Lock()
defer b.mu.Unlock()
b.lines[b.head] = l
b.head = (b.head + 1) % logBufferCap
if b.size < logBufferCap {
b.size++
} else {
b.dropped++
}
}
// Snapshot 依插入順序回傳最後 n 行(複製,安全外流)。
// n <= 0 或 n > size → 回傳全部。
func (b *LogBuffer) Snapshot(n int) []LogLine {
b.mu.Lock()
defer b.mu.Unlock()
if b.size == 0 {
return []LogLine{}
}
if n <= 0 || n > b.size {
n = b.size
}
out := make([]LogLine, 0, n)
// start = 最舊條目的 index
start := (b.head - b.size + logBufferCap) % logBufferCap
skip := b.size - n
for i := 0; i < b.size; i++ {
if i < skip {
continue
}
idx := (start + i) % logBufferCap
out = append(out, b.lines[idx])
}
return out
}
// Reset 清空 ring bufferClearLogs binding 用)。
// 不動磁碟檔;也不重置 rate limit counter。
func (b *LogBuffer) Reset() {
b.mu.Lock()
defer b.mu.Unlock()
b.head = 0
b.size = 0
}
// Size 回傳目前存了多少行thread-safe
func (b *LogBuffer) Size() int {
b.mu.Lock()
defer b.mu.Unlock()
return b.size
}
// Dropped 回傳被覆寫掉的最舊條目總數。
func (b *LogBuffer) Dropped() uint64 {
b.mu.Lock()
defer b.mu.Unlock()
return b.dropped
}
// ShouldEmit 判定此刻是否可以 emit Wails event。
//
// 規則:每 1 秒視窗內最多 emit logRateLimitBurst (=200) 行;超過 → 回 false
// 呼叫端只寫檔 + append buffer不做 EventsEmit。
//
// 實作採「固定視窗」而非「sliding window」夠簡單且效果可接受。
func (b *LogBuffer) ShouldEmit() bool {
now := time.Now().UnixNano()
windowStart := b.rlWindowStart.Load()
if now-windowStart >= int64(logRateLimitWindow) {
// 新視窗:把 windowStart CAS 為 now成功者負責 reset count
if b.rlWindowStart.CompareAndSwap(windowStart, now) {
b.rlCount.Store(0)
}
}
// 在目前視窗內 +1超過 burst 就退回 false
n := b.rlCount.Add(1)
return n <= logRateLimitBurst
}
// parseLogLevel 從一行 log 抽出 levelbest-effort
//
// 支援格式(大小寫不敏感):
// - `... [INFO] ...` / `... [WARN] ...` / `... [ERROR] ...` / `... [DEBUG] ...`
// - `INFO:` / `WARN:` / `ERROR:` / `DEBUG:`
// - `[GIN] 200 | ...` → info< 400
// - `[GIN] 4xx | ...` → warn
// - `[GIN] 5xx | ...` → error
// - 其他 → 空字串(前端當 info 顯示)
//
// 解析失敗不是錯誤level 為空字串即可。
func parseLogLevel(line string) string {
if line == "" {
return ""
}
upper := strings.ToUpper(line)
// 明示 bracket 標記(優先)
switch {
case strings.Contains(upper, "[ERROR]"), strings.Contains(upper, " ERROR:"), strings.HasPrefix(upper, "ERROR:"):
return "error"
case strings.Contains(upper, "[WARN]"), strings.Contains(upper, "[WARNING]"), strings.Contains(upper, " WARN:"), strings.HasPrefix(upper, "WARN:"):
return "warn"
case strings.Contains(upper, "[INFO]"), strings.Contains(upper, " INFO:"), strings.HasPrefix(upper, "INFO:"):
return "info"
case strings.Contains(upper, "[DEBUG]"), strings.Contains(upper, " DEBUG:"), strings.HasPrefix(upper, "DEBUG:"):
return "debug"
}
// Gin access log`[GIN] 200 | 1.2ms | GET /xxx`
if strings.HasPrefix(line, "[GIN]") {
// 找 3 位數 status
for i := 0; i+3 <= len(line); i++ {
if line[i] >= '0' && line[i] <= '9' &&
i+2 < len(line) &&
line[i+1] >= '0' && line[i+1] <= '9' &&
line[i+2] >= '0' && line[i+2] <= '9' {
code := (int(line[i]-'0'))*100 + int(line[i+1]-'0')*10 + int(line[i+2]-'0')
switch {
case code >= 500:
return "error"
case code >= 400:
return "warn"
default:
return "info"
}
}
}
}
return ""
}