依 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>
206 lines
5.9 KiB
Go
206 lines
5.9 KiB
Go
package main
|
||
|
||
// log_buffer.go — M8-4:Wails 控制台 log ring buffer
|
||
//
|
||
// 負責:
|
||
// 1. 2000 行 thread-safe ring buffer
|
||
// 2. LogLine 結構(含 level 解析)
|
||
// 3. Snapshot:取最後 n 行(複製,不回傳 view)
|
||
// 4. Rate limit:1 秒 > 200 條時退化為「只寫檔不 emit」
|
||
//
|
||
// TDD ground truth:
|
||
// - .autoflow/04-architecture/v2/control-panel.md §4.3–§4.5
|
||
// - .autoflow/04-architecture/v2/server-lifecycle.md §4(stdout/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 buffer;rate 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 buffer(ClearLogs 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 抽出 level(best-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 ""
|
||
}
|