feat: AUTO chain rewrite — silent failover+busy skip+pref round-robin+503 tier summary; chain edits reset slot cooldowns (P0/P1); stats by_status + audit jsonl rotation; UI priority-page health badges & status-code card; ctx-menu capture-phase close (outside-press guard); main.go ops warnings; local bundled-Lua verified tests (3 latent bugs fixed); plan.md

This commit is contained in:
JianFeeeee
2026-08-10 23:58:31 +08:00
parent 397af36fbb
commit 88802f9ef6
17 changed files with 2060 additions and 433 deletions

View File

@ -3,7 +3,11 @@ package gateway
import (
"bufio"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"sync"
"time"
)
@ -56,6 +60,7 @@ type Stats struct {
bySrc map[string]*Stat
byKeyModel map[string]map[string]*Stat
byKeySrc map[string]map[string]*Stat
byStatus map[int]*Stat // per http status code aggregates (incl. 402/400)
recs []Req
maxRecs int
auditPath string
@ -64,6 +69,15 @@ type Stats struct {
const hourSec = 3600
// auditRotateBytes rotates the audit file once it grows past this size (the
// file is renamed to <path>.<unix>.old and a fresh one is started); pruning
// keeps at most auditKeepOld rotated files. Both are vars so tests can shrink
// the threshold.
var (
auditRotateBytes int64 = 64 << 20
auditKeepOld = 10
)
func NewStats(maxRecords int) *Stats {
if maxRecords <= 0 {
maxRecords = 3000
@ -74,6 +88,7 @@ func NewStats(maxRecords int) *Stats {
bySrc: map[string]*Stat{},
byKeyModel: map[string]map[string]*Stat{},
byKeySrc: map[string]map[string]*Stat{},
byStatus: map[int]*Stat{},
modelHour: map[string]map[int64]int64{},
maxRecs: maxRecords,
}
@ -98,6 +113,10 @@ func inc(m map[string]*Stat, name string, r Req) {
a = &Stat{}
m[name] = a
}
incStatus(a, name, r)
}
func incStatus(a *Stat, name string, r Req) {
a.Reqs++
if r.OK {
a.OK++
@ -160,6 +179,15 @@ func (s *Stats) Record(r Req) {
s.byKeySrc[r.Key] = ks
}
inc(ks, r.Source, r)
if r.Status > 0 {
name := strconv.Itoa(r.Status)
a := s.byStatus[r.Status]
if a == nil {
a = &Stat{}
s.byStatus[r.Status] = a
}
incStatus(a, name, r)
}
// window bucket for quota enforcement (per source-model pair, per unix hour)
tok := r.Prompt + r.Compl
if tok > 0 && r.Model != "" {
@ -187,28 +215,32 @@ func (s *Stats) Record(r Req) {
s.recs = s.recs[len(s.recs)-s.maxRecs:]
}
if s.auditPath != "" {
if f, err := os.OpenFile(s.auditPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644); err == nil {
if b, err := json.Marshal(r); err == nil {
_, _ = f.Write(append(b, '\n'))
}
_ = f.Close()
s.rotateAuditLocked()
appendAuditLine(s.auditPath, r)
}
}
// rotateAuditLocked renames the audit file to <path>.<unix>.old once it
// exceeds auditRotateBytes and prunes old files beyond auditKeepOld, keeping
// the newest ones. Caller must hold s.mu.
func (s *Stats) rotateAuditLocked() {
if s.auditPath == "" || auditRotateBytes <= 0 {
return
}
if fi, err := os.Stat(s.auditPath); err == nil && fi.Size() < auditRotateBytes {
return
}
ts := time.Now().Unix()
if os.Rename(s.auditPath, fmt.Sprintf("%s.%d.old", s.auditPath, ts)) == nil {
old, _ := filepath.Glob(s.auditPath + ".*.old")
sort.Sort(sort.Reverse(sort.StringSlice(old)))
for i := auditKeepOld; i < len(old); i++ {
_ = os.Remove(old[i])
}
}
}
// AppendAudit writes a generic event line (access log entry, login event,
// config change, …) to the same audit file without touching the aggregates.
func (s *Stats) AppendAudit(obj string, data map[string]interface{}) {
s.mu.Lock()
path := s.auditPath
s.mu.Unlock()
if path == "" {
return
}
row := map[string]interface{}{"obj": obj, "time": time.Now().UnixMilli()}
for k, v := range data {
row[k] = v
}
func appendAuditLine(path string, row interface{}) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return
@ -219,6 +251,22 @@ func (s *Stats) AppendAudit(obj string, data map[string]interface{}) {
}
}
// AppendAudit writes a generic event line (access log entry, login event,
// config change, …) to the same audit file without touching the aggregates.
func (s *Stats) AppendAudit(obj string, data map[string]interface{}) {
row := map[string]interface{}{"obj": obj, "time": time.Now().UnixMilli()}
for k, v := range data {
row[k] = v
}
s.mu.Lock()
defer s.mu.Unlock()
if s.auditPath == "" {
return
}
s.rotateAuditLocked()
appendAuditLine(s.auditPath, row)
}
// ModelTokens returns the tokens consumed per model for one gateway key id
// (used for per-model token quota enforcement).
func (s *Stats) ModelTokens(key string) map[string]int64 {
@ -376,12 +424,22 @@ func (s *Stats) Snapshot(limit int, key string) map[string]interface{} {
total.LatMax = a.LatMax
}
}
bs := make([]agrRow, 0, len(s.byStatus))
for code := range s.byStatus {
bs = append(bs, agrRow{Name: strconv.Itoa(code), Stat: *s.byStatus[code]})
}
sort.Slice(bs, func(i, j int) bool {
ci, _ := strconv.Atoi(bs[i].Name)
cj, _ := strconv.Atoi(bs[j].Name)
return ci < cj
})
return map[string]interface{}{
"active": s.active,
"total": total,
"by_key": rows(byKey),
"by_model": rows(byModel),
"by_source": rows(bySrc),
"by_status": bs,
"records": append([]Req(nil), recs...),
}
}