Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

313 lines
8.7 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"text/tabwriter"
"time"
"go.kenn.io/agentsview/internal/activity"
"go.kenn.io/agentsview/internal/config"
"go.kenn.io/agentsview/internal/db"
"go.kenn.io/agentsview/internal/export"
)
// ActivityReportConfig holds the flags for `agentsview activity report`.
type ActivityReportConfig struct {
Preset string
Date string
From string
To string
Timezone string
Bucket string
Project string
Agent string
Machine string
JSON bool
NoSync bool
Offline bool
}
var activityReportNow = time.Now
// runActivityReport syncs, resolves the range, runs the report, and prints it.
func runActivityReport(cfg ActivityReportConfig) {
ctx := context.Background()
backend, cleanup, err := resolveArchiveQueryBackend(ctx, archiveQueryPolicy{
Offline: cfg.Offline,
NoSync: cfg.NoSync,
ReadOnlyDaemon: archiveQueryUseReadOnlyDaemon,
DirectReadOnlyAction: "query activity directly",
})
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
defer closeArchiveQueryBackend(cleanup)
r, err := backend.ActivityReport(ctx, cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
writeActivityReport(r, cfg.JSON)
}
func writeActivityReport(r activity.Report, jsonOutput bool) {
if jsonOutput {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(r); err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
return
}
printActivityReport(r)
}
func fetchHTTPActivityReport(
ctx context.Context, tr transport, authToken string, cfg ActivityReportConfig,
) (activity.Report, error) {
q := url.Values{}
setIfNotEmpty := func(k, v string) {
if v != "" {
q.Set(k, v)
}
}
setIfNotEmpty("preset", cfg.Preset)
setIfNotEmpty("date", cfg.Date)
setIfNotEmpty("from", cfg.From)
setIfNotEmpty("to", cfg.To)
setIfNotEmpty("timezone", cfg.Timezone)
setIfNotEmpty("bucket", cfg.Bucket)
setIfNotEmpty("project", cfg.Project)
setIfNotEmpty("agent", cfg.Agent)
setIfNotEmpty("machine", cfg.Machine)
endpoint := strings.TrimSuffix(tr.URL, "/") +
"/api/v1/activity/report?" + q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return activity.Report{}, err
}
if authToken != "" {
req.Header.Set("Authorization", "Bearer "+authToken)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return activity.Report{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return activity.Report{}, fmt.Errorf(
"activity report: HTTP %d: %s",
resp.StatusCode, strings.TrimSpace(string(body)),
)
}
var r activity.Report
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil {
return activity.Report{}, err
}
if r.Projects == nil {
r.Projects = map[string]export.ProjectMapEntry{}
}
return r, nil
}
// resolveActivityReportPriced seeds fallback pricing so fresh-DB token usage is
// costed, then resolves the report. runActivityReport and the pricing test
// share this seam so the test exercises the same seeding the command performs.
func resolveActivityReportPriced(
cfg ActivityReportConfig, database *db.DB,
customPricing map[string]config.CustomModelRate,
) (activity.Report, error) {
ensureUsagePricing(database, cfg.Offline, customPricing)
return resolveActivityReport(cfg, database)
}
// resolveActivityReport defaults the timezone and date, resolves the range
// query, and runs the report against the database. It is the testable seam:
// all validation (timezone, bounds, bucket allow-list, range limits) happens
// inside activity.ResolveQuery before any database query.
func resolveActivityReport(
cfg ActivityReportConfig, database *db.DB,
) (activity.Report, error) {
tz := cfg.Timezone
if tz == "" {
tz = localTimezone()
}
date := cfg.Date
if cfg.Preset != "custom" && cfg.From == "" && date == "" {
date = todayIn(tz)
}
input := activity.QueryInput{
Preset: cfg.Preset,
Date: date,
From: cfg.From,
To: cfg.To,
Timezone: tz,
BucketOverride: cfg.Bucket,
}
q, err := activity.ResolveQuery(input, activityReportNow())
if err != nil {
return activity.Report{}, err
}
f := db.AnalyticsFilter{
Timezone: tz,
Project: cfg.Project,
Agent: cfg.Agent,
Machine: cfg.Machine,
ExcludeOneShot: false,
ExcludeAutomated: false,
}
return database.GetActivityReport(context.Background(), f, q)
}
// todayIn returns today's date as YYYY-MM-DD in the given IANA timezone,
// falling back to the local zone when tz is unknown.
func todayIn(tz string) string {
loc, err := time.LoadLocation(tz)
if err != nil {
loc = time.Local
}
return activityReportNow().In(loc).Format("2006-01-02")
}
// printActivityReport renders the human-readable report: a header, totals,
// peak concurrency, top breakdowns, and top sessions. It deliberately omits
// the dense per-bucket timeline, which only the --json output exposes.
func printActivityReport(r activity.Report) {
loc, err := time.LoadLocation(r.Timezone)
if err != nil {
loc = time.UTC
}
fmt.Printf(
"Activity %s to %s (%s, %s buckets)\n",
fmtRangeBound(r.RangeStart, loc), fmtRangeBound(r.RangeEnd, loc),
r.Timezone, r.BucketUnit,
)
if r.Partial {
fmt.Printf("Partial range, data as of %s\n", fmtInstant(r.AsOf, loc))
}
fmt.Println()
printActivityTotals(r.Totals)
fmt.Println()
printActivityPeak(r.Peak, loc)
fmt.Println()
printKeyMinutes("By project", r.ByProject)
printKeyMinutes("By model", r.ByModel)
printKeyMinutes("By agent", r.ByAgent)
printActivitySessions(r.BySession)
}
// printActivityTotals prints the totals block via a tabwriter.
func printActivityTotals(t activity.Totals) {
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintf(w, "Active minutes\t%.1f\n", t.ActiveMinutes)
fmt.Fprintf(w, "Idle minutes\t%.1f\n", t.IdleMinutes)
fmt.Fprintf(w, "Agent minutes\t%.1f\n", t.AgentMinutes)
fmt.Fprintf(w, "Sessions\t%d (%d untimed)\n", t.Sessions, t.UntimedSessions)
fmt.Fprintf(w, "Distinct projects\t%d\n", t.DistinctProjects)
fmt.Fprintf(w, "Distinct models\t%d\n", t.DistinctModels)
fmt.Fprintf(w, "Output tokens\t%d\n", t.OutputTokens)
fmt.Fprintf(w, "Cost\t%s\n", fmtCost(t.Cost))
w.Flush()
}
// printActivityPeak prints peak concurrency and when it occurred, in loc.
func printActivityPeak(p activity.Peak, loc *time.Location) {
fmt.Printf("Peak concurrency: %d agents at %s\n",
p.Agents, fmtInstant(p.At, loc))
}
// printKeyMinutes prints the top 5 rows of a key/agent-minutes breakdown.
func printKeyMinutes(label string, rows []activity.KeyMinutes) {
fmt.Printf("%s (top 5):\n", label)
if len(rows) == 0 {
fmt.Println(" (none)")
fmt.Println()
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
for _, row := range topKeyMinutes(rows, 5) {
fmt.Fprintf(w, " %s\t%.1f min\n",
sanitizeTerminal(row.Key), row.AgentMinutes)
}
w.Flush()
fmt.Println()
}
// printActivitySessions prints the top 5 sessions by appearance order.
func printActivitySessions(rows []activity.SessionRow) {
fmt.Println("Top sessions (top 5):")
if len(rows) == 0 {
fmt.Println(" (none)")
return
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
fmt.Fprintln(w, " TITLE\tPROJECT\tAGENT\tMINUTES\tCOST")
limit := min(len(rows), 5)
for _, s := range rows[:limit] {
fmt.Fprintf(w, " %s\t%s\t%s\t%s\t%s\n",
sanitizeTerminal(s.Title), sanitizeTerminal(s.Project),
sanitizeTerminal(s.Agent),
fmtMinutes(s.AgentMinutes), fmtCost(s.Cost),
)
}
w.Flush()
}
// topKeyMinutes returns the first n rows of rows (already sorted by the query).
func topKeyMinutes(rows []activity.KeyMinutes, n int) []activity.KeyMinutes {
return rows[:min(len(rows), n)]
}
// fmtRangeBound renders an RFC3339 range bound in loc, dropping the time
// component when the local wall time is exactly midnight.
func fmtRangeBound(ts string, loc *time.Location) string {
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
return ts
}
t = t.In(loc)
if t.Hour() == 0 && t.Minute() == 0 && t.Second() == 0 {
return t.Format("2006-01-02")
}
return t.Format("2006-01-02 15:04")
}
// fmtMinutes renders an agent-minutes value, printing a dash for untimed
// sessions whose pointer is nil.
func fmtMinutes(m *float64) string {
if m == nil {
return "—"
}
return fmt.Sprintf("%.1f", *m)
}
// fmtInstant renders a nullable RFC3339 instant in loc as "YYYY-MM-DD HH:MM",
// printing a dash when nil.
func fmtInstant(ts *string, loc *time.Location) string {
if ts == nil {
return "—"
}
if t, err := time.Parse(time.RFC3339, *ts); err == nil {
return t.In(loc).Format("2006-01-02 15:04")
}
return *ts
}