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