fix: AUTO tier order ascending + stats/CSV export completeness + UI key-view toggle & exit affordance

- scheduler: BuildChain sorts tiers ascending so tier 1 (highest priority) is tried first; previously descending inverted the chain (P10-1..P10-5)
- stats/api: handleStatsAPI limit→20000; CSV reads full audit via new Stats.AuditRecords (includes *.old rotation); key_names mapped by keyID() masked key; non-admin filter and keys-csv use masked keys
- server: ring buffer NewStats(10000)
- ui: dash-row card scroll area moved to table container (fix overflow below card); key view toggle (re-click same key returns to global) + kpi-exit affordance; rec-exit span
- chat: chain-failure record now surfaces first failed tier; AUTO comment sync
This commit is contained in:
root
2026-08-11 13:36:23 +08:00
parent 32e2fd521a
commit 854b3e2e37
7 changed files with 104 additions and 43 deletions

View File

@ -130,14 +130,15 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
}
limit := 500
if v := r.URL.Query().Get("limit"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 5000 {
if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 20000 {
limit = n
}
}
key := r.URL.Query().Get("key")
if reqRole(r.Context()) != "admin" {
// user keys may only see their own usage - use full key for internal filtering
key = reqKey(r.Context())
// user keys may only see their own usage - records and aggregates are
// keyed by the masked id (keyID), so filter on that masked form.
key = keyID(reqKey(r.Context()))
}
if r.URL.Query().Get("export") == "csv" {
from, _ := strconv.ParseInt(r.URL.Query().Get("from"), 10, 64)
@ -150,10 +151,10 @@ func (g *Gateway) handleStatsAPI(w http.ResponseWriter, r *http.Request) {
cw := csv.NewWriter(w)
names := map[string]string{}
for _, k := range g.core.ListKeys() {
names[k.Key] = k.Name
names[keyID(k.Key)] = k.Name
}
_ = cw.Write([]string{"time", "key", "key_name", "type", "model", "source", "status", "ok", "prompt_tokens", "completion_tokens", "latency_ms", "error"})
for _, rec := range g.stats.Records(from, to, key) {
for _, rec := range g.stats.AuditRecords(from, to, key) {
_ = cw.Write([]string{
time.UnixMilli(rec.Time).Format(time.RFC3339),
rec.Key,
@ -180,7 +181,13 @@ if r.URL.Query().Get("export") == "keys-csv" {
// Use the same key filtering as the JSON API
exportKey := r.URL.Query().Get("key")
if reqRole(r.Context()) != "admin" {
exportKey = reqKey(r.Context())
exportKey = keyID(reqKey(r.Context()))
}
// by_key rows are keyed by the masked id (keyID); build a masked-id ->
// record map so names/roles/models resolve for the export.
byMasked := map[string]config.GWKey{}
for _, k := range g.core.ListKeys() {
byMasked[keyID(k.Key)] = k
}
snap := g.stats.Snapshot(0, exportKey)
byKeyRaw, ok := snap["by_key"].([]StatsRow)
@ -188,8 +195,7 @@ if r.URL.Query().Get("export") == "keys-csv" {
byKeyRaw = []StatsRow{}
}
for _, row := range byKeyRaw {
key := row.Name
keyInfo, found := g.core.FindKey(key)
keyInfo, found := byMasked[row.Name]
name := ""
role := ""
models := ""
@ -237,7 +243,7 @@ if r.URL.Query().Get("export") == "keys-csv" {
snap := g.stats.Snapshot(limit, key)
keyNames := map[string]string{}
for _, k := range g.core.ListKeys() {
keyNames[k.Key] = k.Name
keyNames[keyID(k.Key)] = k.Name
}
snap["key_names"] = keyNames
writeJSON(w, http.StatusOK, snap)