Skip to content

Commit 028f76e

Browse files
authored
fix: Stats TUI (#14)
* fix: Stats TUI * fix: TUI wrapping bug * fix: stats TUI header * fix: Code review fixes
1 parent a714ab3 commit 028f76e

File tree

11 files changed

+284
-37
lines changed

11 files changed

+284
-37
lines changed

cli/stats.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
type statsParams struct {
1414
since string
15+
until string
1516
agent string
1617
}
1718

@@ -27,6 +28,7 @@ Displays overview metrics, activity breakdown, agent stats, timeline,
2728
code changes, command results, error rates, and session info.`,
2829
Example: ` gryph stats
2930
gryph stats --since 7d
31+
gryph stats --since 2w --until 1w
3032
gryph stats --since 30d --agent claude-code`,
3133
RunE: func(cmd *cobra.Command, args []string) error {
3234
ctx := context.Background()
@@ -68,6 +70,14 @@ code changes, command results, error rates, and session info.`,
6870
opts.Since = &t
6971
}
7072

73+
if p.until != "" {
74+
t, err := parseDuration(p.until)
75+
if err != nil {
76+
return fmt.Errorf("invalid --until value %q: %w", p.until, err)
77+
}
78+
opts.Until = &t
79+
}
80+
7181
prog := tea.NewProgram(stats.New(opts), tea.WithAltScreen())
7282
_, err = prog.Run()
7383

@@ -76,6 +86,7 @@ code changes, command results, error rates, and session info.`,
7686
}
7787

7888
cmd.Flags().StringVar(&p.since, "since", "today", "time range: today, 7d, 30d, all, or duration (30m, 1h, 2w)")
89+
cmd.Flags().StringVar(&p.until, "until", "", "end of time window (same syntax as --since)")
7990
cmd.Flags().StringVar(&p.agent, "agent", "", "filter by agent name")
8091

8192
return cmd

core/session/session.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ func (f *SessionFilter) WithSince(t time.Time) *SessionFilter {
111111
return f
112112
}
113113

114+
// WithUntil sets the Until filter.
115+
func (f *SessionFilter) WithUntil(t time.Time) *SessionFilter {
116+
f.Until = &t
117+
return f
118+
}
119+
114120
// WithLimit sets the Limit.
115121
func (f *SessionFilter) WithLimit(limit int) *SessionFilter {
116122
f.Limit = limit

tui/component/stats/agents.go

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ func renderAgents(data *StatsData, width, height int) string {
1414
}
1515

1616
var b strings.Builder
17-
b.WriteString(fmt.Sprintf(" %s %s %s %s\n",
18-
labelStyle.Width(14).Render("Agent"),
19-
labelStyle.Width(6).Align(lipgloss.Right).Render("Sess"),
20-
labelStyle.Width(8).Align(lipgloss.Right).Render("Events"),
21-
labelStyle.Width(6).Align(lipgloss.Right).Render("Errs"),
17+
b.WriteString(fmt.Sprintf(" %s %s %s %s %s %s\n",
18+
labelStyle.Width(12).Render("Agent"),
19+
labelStyle.Width(5).Align(lipgloss.Right).Render("Sess"),
20+
labelStyle.Width(6).Align(lipgloss.Right).Render("Evts"),
21+
labelStyle.Width(5).Align(lipgloss.Right).Render("Wrts"),
22+
labelStyle.Width(5).Align(lipgloss.Right).Render("Cmds"),
23+
labelStyle.Width(5).Align(lipgloss.Right).Render("Errs"),
2224
))
2325

2426
maxAgents := height - 3
@@ -31,13 +33,15 @@ func renderAgents(data *StatsData, width, height int) string {
3133

3234
for _, a := range data.Agents[:maxAgents] {
3335
name := lipgloss.NewStyle().Foreground(agentColor(a.Name)).Render(
34-
tui.TruncateString(a.Name, 14),
36+
tui.TruncateString(a.Name, 12),
3537
)
36-
b.WriteString(fmt.Sprintf(" %s %s %s %s\n",
37-
tui.PadRightVisible(name, 14),
38-
valueStyle.Width(6).Align(lipgloss.Right).Render(fmt.Sprintf("%d", a.Sessions)),
39-
valueStyle.Width(8).Align(lipgloss.Right).Render(tui.FormatNumber(a.Events)),
40-
redValueStyle.Width(6).Align(lipgloss.Right).Render(fmt.Sprintf("%d", a.Errors)),
38+
b.WriteString(fmt.Sprintf(" %s %s %s %s %s %s\n",
39+
tui.PadRightVisible(name, 12),
40+
valueStyle.Width(5).Align(lipgloss.Right).Render(fmt.Sprintf("%d", a.Sessions)),
41+
valueStyle.Width(6).Align(lipgloss.Right).Render(tui.FormatNumber(a.Events)),
42+
valueStyle.Width(5).Align(lipgloss.Right).Render(fmt.Sprintf("%d", a.FilesWritten)),
43+
valueStyle.Width(5).Align(lipgloss.Right).Render(fmt.Sprintf("%d", a.Commands)),
44+
redValueStyle.Width(5).Align(lipgloss.Right).Render(fmt.Sprintf("%d", a.Errors)),
4145
))
4246
}
4347

tui/component/stats/data.go

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package stats
22

33
import (
44
"context"
5+
"fmt"
56
"sort"
67
"time"
78

@@ -33,6 +34,11 @@ type CommandStat struct {
3334
FailCount int
3435
}
3536

37+
type TimelineBucket struct {
38+
Label string
39+
Count int
40+
}
41+
3642
type StatsData struct {
3743
TotalEvents int
3844
TotalSessions int
@@ -48,7 +54,7 @@ type StatsData struct {
4854

4955
Agents []AgentStat
5056

51-
HourlyBuckets [24]int
57+
TimelineBuckets []TimelineBucket
5258

5359
LinesAdded int
5460
LinesRemoved int
@@ -69,18 +75,22 @@ type StatsData struct {
6975
AvgActionsPerSess float64
7076
LongestSession time.Duration
7177
ShortestSession time.Duration
78+
PeakConcurrent int
7279

7380
TimeSpanStart time.Time
7481
TimeSpanEnd time.Time
7582
}
7683

77-
func computeStats(ctx context.Context, store storage.Store, since *time.Time, agentFilter string) (*StatsData, error) {
84+
func computeStats(ctx context.Context, store storage.Store, since, until *time.Time, agentFilter string) (*StatsData, error) {
7885
data := &StatsData{}
7986

8087
sessionFilter := session.NewSessionFilter().WithLimit(10000)
8188
if since != nil {
8289
sessionFilter = sessionFilter.WithSince(*since)
8390
}
91+
if until != nil {
92+
sessionFilter = sessionFilter.WithUntil(*until)
93+
}
8494
if agentFilter != "" {
8595
sessionFilter = sessionFilter.WithAgent(agentFilter)
8696
}
@@ -135,6 +145,8 @@ func computeStats(ctx context.Context, store storage.Store, since *time.Time, ag
135145
data.AvgDuration = totalDuration / time.Duration(sessionCount)
136146
}
137147

148+
data.PeakConcurrent = computePeakConcurrent(sessions)
149+
138150
data.UniqueAgents = len(agentMap)
139151
for _, as := range agentMap {
140152
data.Agents = append(data.Agents, *as)
@@ -147,6 +159,9 @@ func computeStats(ctx context.Context, store storage.Store, since *time.Time, ag
147159
if since != nil {
148160
eventFilter = eventFilter.WithSince(*since)
149161
}
162+
if until != nil {
163+
eventFilter = eventFilter.WithUntil(*until)
164+
}
150165
if agentFilter != "" {
151166
eventFilter = eventFilter.WithAgents(agentFilter)
152167
}
@@ -177,9 +192,6 @@ func computeStats(ctx context.Context, store storage.Store, since *time.Time, ag
177192
data.TimeSpanEnd = e.Timestamp
178193
}
179194

180-
hour := e.Timestamp.Local().Hour()
181-
data.HourlyBuckets[hour]++
182-
183195
switch e.ResultStatus {
184196
case events.ResultError:
185197
data.TotalErrors++
@@ -269,5 +281,113 @@ func computeStats(ctx context.Context, store storage.Store, since *time.Time, ag
269281
data.TopCommands = data.TopCommands[:10]
270282
}
271283

284+
data.TimelineBuckets = computeTimelineBuckets(evts, since, until)
285+
272286
return data, nil
273287
}
288+
289+
func computeTimelineBuckets(evts []*events.Event, since, until *time.Time) []TimelineBucket {
290+
now := time.Now()
291+
effectiveSince := now.Add(-24 * time.Hour)
292+
effectiveUntil := now
293+
if since != nil {
294+
effectiveSince = *since
295+
}
296+
if until != nil {
297+
effectiveUntil = *until
298+
}
299+
300+
span := effectiveUntil.Sub(effectiveSince)
301+
302+
if span <= 24*time.Hour {
303+
return computeHourlyBuckets(evts)
304+
}
305+
return computeDailyBuckets(evts, effectiveSince, effectiveUntil)
306+
}
307+
308+
func computeHourlyBuckets(evts []*events.Event) []TimelineBucket {
309+
counts := [24]int{}
310+
for _, e := range evts {
311+
h := e.Timestamp.Local().Hour()
312+
counts[h]++
313+
}
314+
buckets := make([]TimelineBucket, 24)
315+
for h := 0; h < 24; h++ {
316+
buckets[h] = TimelineBucket{
317+
Label: fmt.Sprintf("%02d", h),
318+
Count: counts[h],
319+
}
320+
}
321+
return buckets
322+
}
323+
324+
func computeDailyBuckets(evts []*events.Event, since, until time.Time) []TimelineBucket {
325+
sinceLocal := since.Local()
326+
untilLocal := until.Local()
327+
startDay := time.Date(sinceLocal.Year(), sinceLocal.Month(), sinceLocal.Day(), 0, 0, 0, 0, sinceLocal.Location())
328+
endDay := time.Date(untilLocal.Year(), untilLocal.Month(), untilLocal.Day(), 0, 0, 0, 0, untilLocal.Location())
329+
330+
days := int(endDay.Sub(startDay).Hours()/24) + 1
331+
if days < 1 {
332+
days = 1
333+
}
334+
335+
buckets := make([]TimelineBucket, days)
336+
for i := 0; i < days; i++ {
337+
day := startDay.AddDate(0, 0, i)
338+
if days <= 14 {
339+
buckets[i].Label = day.Format("Mon")
340+
} else {
341+
buckets[i].Label = day.Format("Jan 2")
342+
}
343+
}
344+
345+
for _, e := range evts {
346+
local := e.Timestamp.Local()
347+
idx := int(time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location()).Sub(startDay).Hours() / 24)
348+
if idx >= 0 && idx < days {
349+
buckets[idx].Count++
350+
}
351+
}
352+
353+
return buckets
354+
}
355+
356+
func computePeakConcurrent(sessions []*session.Session) int {
357+
if len(sessions) == 0 {
358+
return 0
359+
}
360+
361+
type endpoint struct {
362+
t time.Time
363+
delta int
364+
}
365+
366+
var points []endpoint
367+
for _, s := range sessions {
368+
if s.EndedAt.IsZero() {
369+
continue
370+
}
371+
points = append(points,
372+
endpoint{t: s.StartedAt, delta: 1},
373+
endpoint{t: s.EndedAt, delta: -1},
374+
)
375+
}
376+
377+
sort.Slice(points, func(i, j int) bool {
378+
if points[i].t.Equal(points[j].t) {
379+
return points[i].delta > points[j].delta
380+
}
381+
return points[i].t.Before(points[j].t)
382+
})
383+
384+
peak := 0
385+
current := 0
386+
for _, p := range points {
387+
current += p.delta
388+
if current > peak {
389+
peak = current
390+
}
391+
}
392+
return peak
393+
}

tui/component/stats/header.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,29 @@ type headerModel struct {
1010
agentFilter string
1111
lastRefresh time.Time
1212
customSince *time.Time
13+
customUntil *time.Time
1314
}
1415

15-
func newHeaderModel(timeRange TimeRange, agentFilter string, customSince *time.Time) headerModel {
16+
func newHeaderModel(timeRange TimeRange, agentFilter string, customSince, customUntil *time.Time) headerModel {
1617
return headerModel{
1718
timeRange: timeRange,
1819
agentFilter: agentFilter,
1920
customSince: customSince,
21+
customUntil: customUntil,
2022
}
2123
}
2224

2325
func (h headerModel) view(width int) string {
2426
title := "gryph stats"
2527
var rangeTxt string
26-
if h.customSince != nil {
28+
if h.customSince != nil && h.customUntil != nil {
29+
rangeTxt = fmt.Sprintf("%s – %s",
30+
h.customSince.Local().Format("Jan 2 15:04"),
31+
h.customUntil.Local().Format("Jan 2 15:04"))
32+
} else if h.customSince != nil {
2733
rangeTxt = fmt.Sprintf("Since %s", h.customSince.Local().Format("2006-01-02 15:04"))
34+
} else if h.customUntil != nil {
35+
rangeTxt = fmt.Sprintf("%s until %s", h.timeRange.String(), h.customUntil.Local().Format("Jan 2 15:04"))
2836
} else {
2937
rangeTxt = h.timeRange.String()
3038
}

tui/component/stats/help.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ var helpBindings = []struct {
2828
{"w", "Time range: 7 days"},
2929
{"m", "Time range: 30 days"},
3030
{"a", "Time range: All"},
31+
{"[", "Shift window back"},
32+
{"]", "Shift window forward"},
3133
{"r", "Force refresh"},
3234
}
3335

0 commit comments

Comments
 (0)