|
| 1 | +package main |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "encoding/json" |
| 6 | + "fmt" |
| 7 | + "os" |
| 8 | + "text/tabwriter" |
| 9 | + "time" |
| 10 | + |
| 11 | + "go.kenn.io/agentsview/internal/activity" |
| 12 | + "go.kenn.io/agentsview/internal/db" |
| 13 | +) |
| 14 | + |
| 15 | +// ActivityReportConfig holds the flags for `agentsview activity report`. |
| 16 | +type ActivityReportConfig struct { |
| 17 | + Preset string |
| 18 | + Date string |
| 19 | + From string |
| 20 | + To string |
| 21 | + Timezone string |
| 22 | + Bucket string |
| 23 | + Project string |
| 24 | + Agent string |
| 25 | + Machine string |
| 26 | + JSON bool |
| 27 | + NoSync bool |
| 28 | + Offline bool |
| 29 | +} |
| 30 | + |
| 31 | +// runActivityReport syncs, resolves the range, runs the report, and prints it. |
| 32 | +func runActivityReport(cfg ActivityReportConfig) { |
| 33 | + database, appCfg := openUsageDB() |
| 34 | + defer database.Close() |
| 35 | + |
| 36 | + ensureFreshData(appCfg, database, cfg.NoSync) |
| 37 | + |
| 38 | + r, err := resolveActivityReportPriced(cfg, database) |
| 39 | + if err != nil { |
| 40 | + fmt.Fprintf(os.Stderr, "error: %v\n", err) |
| 41 | + os.Exit(1) |
| 42 | + } |
| 43 | + |
| 44 | + if cfg.JSON { |
| 45 | + enc := json.NewEncoder(os.Stdout) |
| 46 | + enc.SetIndent("", " ") |
| 47 | + if err := enc.Encode(r); err != nil { |
| 48 | + fmt.Fprintf(os.Stderr, "error: %v\n", err) |
| 49 | + os.Exit(1) |
| 50 | + } |
| 51 | + return |
| 52 | + } |
| 53 | + |
| 54 | + printActivityReport(r) |
| 55 | +} |
| 56 | + |
| 57 | +// resolveActivityReportPriced seeds fallback pricing so fresh-DB token usage is |
| 58 | +// costed, then resolves the report. runActivityReport and the pricing test |
| 59 | +// share this seam so the test exercises the same seeding the command performs. |
| 60 | +func resolveActivityReportPriced( |
| 61 | + cfg ActivityReportConfig, database *db.DB, |
| 62 | +) (activity.Report, error) { |
| 63 | + ensurePricing(database, cfg.Offline) |
| 64 | + return resolveActivityReport(cfg, database) |
| 65 | +} |
| 66 | + |
| 67 | +// resolveActivityReport defaults the timezone and date, resolves the range |
| 68 | +// query, and runs the report against the database. It is the testable seam: |
| 69 | +// all validation (timezone, bounds, bucket allow-list, range limits) happens |
| 70 | +// inside activity.ResolveQuery before any database query. |
| 71 | +func resolveActivityReport( |
| 72 | + cfg ActivityReportConfig, database *db.DB, |
| 73 | +) (activity.Report, error) { |
| 74 | + tz := cfg.Timezone |
| 75 | + if tz == "" { |
| 76 | + tz = localTimezone() |
| 77 | + } |
| 78 | + |
| 79 | + date := cfg.Date |
| 80 | + if cfg.Preset != "custom" && cfg.From == "" && date == "" { |
| 81 | + date = todayIn(tz) |
| 82 | + } |
| 83 | + |
| 84 | + input := activity.QueryInput{ |
| 85 | + Preset: cfg.Preset, |
| 86 | + Date: date, |
| 87 | + From: cfg.From, |
| 88 | + To: cfg.To, |
| 89 | + Timezone: tz, |
| 90 | + BucketOverride: cfg.Bucket, |
| 91 | + } |
| 92 | + q, err := activity.ResolveQuery(input, time.Now()) |
| 93 | + if err != nil { |
| 94 | + return activity.Report{}, err |
| 95 | + } |
| 96 | + |
| 97 | + f := db.AnalyticsFilter{ |
| 98 | + Timezone: tz, |
| 99 | + Project: cfg.Project, |
| 100 | + Agent: cfg.Agent, |
| 101 | + Machine: cfg.Machine, |
| 102 | + ExcludeOneShot: false, |
| 103 | + ExcludeAutomated: false, |
| 104 | + } |
| 105 | + return database.GetActivityReport(context.Background(), f, q) |
| 106 | +} |
| 107 | + |
| 108 | +// todayIn returns today's date as YYYY-MM-DD in the given IANA timezone, |
| 109 | +// falling back to the local zone when tz is unknown. |
| 110 | +func todayIn(tz string) string { |
| 111 | + loc, err := time.LoadLocation(tz) |
| 112 | + if err != nil { |
| 113 | + loc = time.Local |
| 114 | + } |
| 115 | + return time.Now().In(loc).Format("2006-01-02") |
| 116 | +} |
| 117 | + |
| 118 | +// printActivityReport renders the human-readable report: a header, totals, |
| 119 | +// peak concurrency, top breakdowns, and top sessions. It deliberately omits |
| 120 | +// the dense per-bucket timeline, which only the --json output exposes. |
| 121 | +func printActivityReport(r activity.Report) { |
| 122 | + loc, err := time.LoadLocation(r.Timezone) |
| 123 | + if err != nil { |
| 124 | + loc = time.UTC |
| 125 | + } |
| 126 | + fmt.Printf( |
| 127 | + "Activity %s to %s (%s, %s buckets)\n", |
| 128 | + fmtRangeBound(r.RangeStart, loc), fmtRangeBound(r.RangeEnd, loc), |
| 129 | + r.Timezone, r.BucketUnit, |
| 130 | + ) |
| 131 | + if r.Partial { |
| 132 | + fmt.Printf("Partial range, data as of %s\n", fmtInstant(r.AsOf, loc)) |
| 133 | + } |
| 134 | + fmt.Println() |
| 135 | + |
| 136 | + printActivityTotals(r.Totals) |
| 137 | + fmt.Println() |
| 138 | + printActivityPeak(r.Peak, loc) |
| 139 | + fmt.Println() |
| 140 | + printKeyMinutes("By project", r.ByProject) |
| 141 | + printKeyMinutes("By model", r.ByModel) |
| 142 | + printKeyMinutes("By agent", r.ByAgent) |
| 143 | + printActivitySessions(r.BySession) |
| 144 | +} |
| 145 | + |
| 146 | +// printActivityTotals prints the totals block via a tabwriter. |
| 147 | +func printActivityTotals(t activity.Totals) { |
| 148 | + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) |
| 149 | + fmt.Fprintf(w, "Active minutes\t%.1f\n", t.ActiveMinutes) |
| 150 | + fmt.Fprintf(w, "Idle minutes\t%.1f\n", t.IdleMinutes) |
| 151 | + fmt.Fprintf(w, "Agent minutes\t%.1f\n", t.AgentMinutes) |
| 152 | + fmt.Fprintf(w, "Sessions\t%d (%d untimed)\n", t.Sessions, t.UntimedSessions) |
| 153 | + fmt.Fprintf(w, "Distinct projects\t%d\n", t.DistinctProjects) |
| 154 | + fmt.Fprintf(w, "Distinct models\t%d\n", t.DistinctModels) |
| 155 | + fmt.Fprintf(w, "Output tokens\t%d\n", t.OutputTokens) |
| 156 | + fmt.Fprintf(w, "Cost\t%s\n", fmtCost(t.Cost)) |
| 157 | + w.Flush() |
| 158 | +} |
| 159 | + |
| 160 | +// printActivityPeak prints peak concurrency and when it occurred, in loc. |
| 161 | +func printActivityPeak(p activity.Peak, loc *time.Location) { |
| 162 | + fmt.Printf("Peak concurrency: %d agents at %s\n", |
| 163 | + p.Agents, fmtInstant(p.At, loc)) |
| 164 | +} |
| 165 | + |
| 166 | +// printKeyMinutes prints the top 5 rows of a key/agent-minutes breakdown. |
| 167 | +func printKeyMinutes(label string, rows []activity.KeyMinutes) { |
| 168 | + fmt.Printf("%s (top 5):\n", label) |
| 169 | + if len(rows) == 0 { |
| 170 | + fmt.Println(" (none)") |
| 171 | + fmt.Println() |
| 172 | + return |
| 173 | + } |
| 174 | + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) |
| 175 | + for _, row := range topKeyMinutes(rows, 5) { |
| 176 | + fmt.Fprintf(w, " %s\t%.1f min\n", row.Key, row.AgentMinutes) |
| 177 | + } |
| 178 | + w.Flush() |
| 179 | + fmt.Println() |
| 180 | +} |
| 181 | + |
| 182 | +// printActivitySessions prints the top 5 sessions by appearance order. |
| 183 | +func printActivitySessions(rows []activity.SessionRow) { |
| 184 | + fmt.Println("Top sessions (top 5):") |
| 185 | + if len(rows) == 0 { |
| 186 | + fmt.Println(" (none)") |
| 187 | + return |
| 188 | + } |
| 189 | + w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) |
| 190 | + fmt.Fprintln(w, " TITLE\tPROJECT\tAGENT\tMINUTES\tCOST") |
| 191 | + limit := min(len(rows), 5) |
| 192 | + for _, s := range rows[:limit] { |
| 193 | + fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n", |
| 194 | + s.Title, s.Project, s.Agent, |
| 195 | + fmtMinutes(s.AgentMinutes), fmtCost(s.Cost), |
| 196 | + ) |
| 197 | + } |
| 198 | + w.Flush() |
| 199 | +} |
| 200 | + |
| 201 | +// topKeyMinutes returns the first n rows of rows (already sorted by the query). |
| 202 | +func topKeyMinutes(rows []activity.KeyMinutes, n int) []activity.KeyMinutes { |
| 203 | + return rows[:min(len(rows), n)] |
| 204 | +} |
| 205 | + |
| 206 | +// fmtRangeBound renders an RFC3339 range bound in loc, dropping the time |
| 207 | +// component when the local wall time is exactly midnight. |
| 208 | +func fmtRangeBound(ts string, loc *time.Location) string { |
| 209 | + t, err := time.Parse(time.RFC3339, ts) |
| 210 | + if err != nil { |
| 211 | + return ts |
| 212 | + } |
| 213 | + t = t.In(loc) |
| 214 | + if t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 { |
| 215 | + return t.Format("2006-01-02") |
| 216 | + } |
| 217 | + return t.Format("2006-01-02 15:04") |
| 218 | +} |
| 219 | + |
| 220 | +// fmtMinutes renders an agent-minutes value, printing a dash for untimed |
| 221 | +// sessions whose pointer is nil. |
| 222 | +func fmtMinutes(m *float64) string { |
| 223 | + if m == nil { |
| 224 | + return "—" |
| 225 | + } |
| 226 | + return fmt.Sprintf("%.1f", *m) |
| 227 | +} |
| 228 | + |
| 229 | +// fmtInstant renders a nullable RFC3339 instant in loc as "YYYY-MM-DD HH:MM", |
| 230 | +// printing a dash when nil. |
| 231 | +func fmtInstant(ts *string, loc *time.Location) string { |
| 232 | + if ts == nil { |
| 233 | + return "—" |
| 234 | + } |
| 235 | + if t, err := time.Parse(time.RFC3339, *ts); err == nil { |
| 236 | + return t.In(loc).Format("2006-01-02 15:04") |
| 237 | + } |
| 238 | + return *ts |
| 239 | +} |
0 commit comments