feat(server,tui): session stats pane — metrics over the WS bus + Bubble Tea overlay
Surfaces the observability metrics (observability-spec §3-tier-2) in the Go TUI: a session-summary pane showing duration, token throughput per provider, tool time, approval latency per tier, failure counts, and the session wall-time accounting split. Press `S` (or palette → "session stats"). Mirrors the artifacts/config fetch pattern exactly — a WS request/response, not a new transport. Server-side reuses the already-tested MetricsInspectionService (one MetricsReport definition, two wires: REST `correx stats` + WS). Server: - ClientMessage.GetSessionStats + ServerMessage.SessionStats(@SerialName session.stats), reusing MetricsReport as the nested payload. - StreamQueries.sessionStats replays via MetricsInspectionService (pure read, replay-neutral); routed in GlobalStreamHandler. - ServerMessageSerializationTest golden pins the session.stats wire format. TUI (Go/Bubble Tea): - protocol: TypeSessionStats + StatsDto/nested structs + GetSessionStats encoder + golden decode test (cross-language contract). - OverlayStats pane (statsModal), `S` key + palette entry + footer hint, loading/cache-by-session state, bounded breakdown rows. - demo `stats` preview case for serverless visual verification.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -43,6 +44,8 @@ func (m Model) renderOverlay(base string) string {
|
||||
return m.center(m.artifactsModal())
|
||||
case OverlayConfig:
|
||||
return m.center(m.configModal())
|
||||
case OverlayStats:
|
||||
return m.center(m.statsModal())
|
||||
}
|
||||
return base
|
||||
}
|
||||
@@ -545,6 +548,116 @@ func (m Model) artifactsModal() string {
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
// statsBreakdownRows caps how many per-provider / per-tool / per-tier rows the pane
|
||||
// shows; the server already sorts each list by descending cost, so the cap keeps the
|
||||
// heaviest contributors and the modal bounded.
|
||||
const statsBreakdownRows = 6
|
||||
|
||||
// humanDurMs renders a millisecond span as a compact "1h 2m 3s" string.
|
||||
func humanDurMs(ms int64) string {
|
||||
if ms <= 0 {
|
||||
return "0s"
|
||||
}
|
||||
total := ms / 1000
|
||||
h := total / 3600
|
||||
mn := (total / 60) % 60
|
||||
s := total % 60
|
||||
out := ""
|
||||
if h > 0 {
|
||||
out += itoa(int(h)) + "h "
|
||||
}
|
||||
if h > 0 || mn > 0 {
|
||||
out += itoa(int(mn)) + "m "
|
||||
}
|
||||
return out + itoa(int(s)) + "s"
|
||||
}
|
||||
|
||||
func (m Model) statsModal() string {
|
||||
t := m.theme
|
||||
w := m.modalWidth()
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(m.titleLine("session stats"))
|
||||
if m.selectedID != "" {
|
||||
b.WriteString(mbg(t, " ("+shortID(m.selectedID)+")", t.P.Faint))
|
||||
}
|
||||
b.WriteString("\n\n")
|
||||
|
||||
if m.statsLoading || m.stats == nil || m.statsFor != m.selectedID {
|
||||
msg := "loading…"
|
||||
if !m.statsLoading && m.stats == nil {
|
||||
msg = "no stats for this session"
|
||||
}
|
||||
b.WriteString(mbg(t, " "+msg, t.P.Faint) + "\n")
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
s := m.stats
|
||||
section := func(label string) string {
|
||||
return lipgloss.NewStyle().Foreground(t.P.Accent2).Background(t.P.BgPanel).Bold(true).Render(label)
|
||||
}
|
||||
line := func(s string) string { return mbg(t, s, t.P.Fg) }
|
||||
faint := func(s string) string { return mbg(t, s, t.P.Faint) }
|
||||
|
||||
// header row: duration + event count
|
||||
b.WriteString(faint(" duration ") + line(humanDurMs(s.SessionDurationMs)) +
|
||||
faint(" events ") + line(itoa(int(s.EventCount))) + "\n\n")
|
||||
|
||||
// inference
|
||||
b.WriteString(section("Inference") + "\n")
|
||||
b.WriteString(line(fmt.Sprintf(" %d calls · %d prompt + %d completion = %d tok · %.1f tok/s",
|
||||
s.InferenceCount, s.PromptTokens, s.CompletionTokens, s.PromptTokens+s.CompletionTokens, s.TokensPerSecond)) + "\n")
|
||||
for i, p := range s.PerProvider {
|
||||
if i >= statsBreakdownRows {
|
||||
b.WriteString(faint(fmt.Sprintf(" … +%d more", len(s.PerProvider)-statsBreakdownRows)) + "\n")
|
||||
break
|
||||
}
|
||||
b.WriteString(faint(fmt.Sprintf(" %-20s %3d calls %6d tok %7d ms %.1f tok/s",
|
||||
padRaw(p.Provider, 20), p.CompletedCount, p.PromptTokens+p.CompletionTokens, p.TotalLatencyMs, p.TokensPerSecond)) + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// tools
|
||||
b.WriteString(section("Tools") + "\n")
|
||||
b.WriteString(line(fmt.Sprintf(" %d calls · %d ms", s.ToolCount, s.ToolMs)) + "\n")
|
||||
for i, tl := range s.PerTool {
|
||||
if i >= statsBreakdownRows {
|
||||
b.WriteString(faint(fmt.Sprintf(" … +%d more", len(s.PerTool)-statsBreakdownRows)) + "\n")
|
||||
break
|
||||
}
|
||||
b.WriteString(faint(fmt.Sprintf(" %-24s %3d %7d ms", padRaw(tl.ToolName, 24), tl.CompletedCount, tl.TotalDurationMs)) + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// approvals
|
||||
b.WriteString(section("Approvals") + "\n")
|
||||
b.WriteString(line(fmt.Sprintf(" %d req · %d resolved · %d pending · avg wait %d ms",
|
||||
s.ApprovalsRequested, s.ApprovalsResolved, s.ApprovalsPending, s.AvgApprovalWaitMs)) + "\n")
|
||||
for _, tr := range s.PerTier {
|
||||
b.WriteString(faint(fmt.Sprintf(" %-4s req %d res %d avg %d ms", tr.Tier, tr.RequestedCount, tr.ResolvedCount, tr.AvgWaitMs)) + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// failures
|
||||
f := s.Failures
|
||||
b.WriteString(section("Failures") + "\n")
|
||||
b.WriteString(line(fmt.Sprintf(" inference %d · timeouts %d · tool %d · rejected %d · stage %d · workflow %d",
|
||||
f.InferenceFailures, f.InferenceTimeouts, f.ToolFailures, f.ToolRejections, f.StageFailures, f.WorkflowFailures)) + "\n\n")
|
||||
|
||||
// time accounting
|
||||
other := 100.0 - s.InferencePct - s.ToolPct - s.ApprovalWaitPct
|
||||
if other < 0 {
|
||||
other = 0
|
||||
}
|
||||
b.WriteString(section("Time") + " " +
|
||||
line(fmt.Sprintf("inference %.1f%% · tools %.1f%% · approval-wait %.1f%% · other %.1f%%",
|
||||
s.InferencePct, s.ToolPct, s.ApprovalWaitPct, other)) + "\n")
|
||||
|
||||
b.WriteString("\n" + modalHints(t, [][2]string{{"S/esc", "close"}}))
|
||||
return t.Overlay.Width(w).Render(b.String())
|
||||
}
|
||||
|
||||
// shortID trims a long artifact id for the list column while staying identifiable.
|
||||
func shortID(id string) string {
|
||||
if len(id) <= 14 {
|
||||
|
||||
Reference in New Issue
Block a user