Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 18 additions & 13 deletions cmd/agentsview/activity.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import (
"go.kenn.io/agentsview/internal/db"
)

// ActivityReportConfig holds the flags for `agentsview activity report`.
type ActivityReportConfig struct {
Preset string
Date string
Expand All @@ -33,7 +32,6 @@ type ActivityReportConfig struct {
Offline bool
}

// runActivityReport syncs, resolves the range, runs the report, and prints it.
func runActivityReport(cfg ActivityReportConfig) {
ctx := context.Background()
backend, cleanup, err := resolveArchiveQueryBackend(ctx, archiveQueryPolicy{
Expand Down Expand Up @@ -180,9 +178,6 @@ func todayIn(tz string) string {
return time.Now().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 {
Expand All @@ -205,10 +200,10 @@ func printActivityReport(r activity.Report) {
printKeyMinutes("By project", r.ByProject)
printKeyMinutes("By model", r.ByModel)
printKeyMinutes("By agent", r.ByAgent)
printBranchKeyMinutes("By branch", r.ByBranch)
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)
Expand All @@ -222,14 +217,24 @@ func printActivityTotals(t activity.Totals) {
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) {
printLabeledKeyMinutes(label, rows, nil)
}

func printBranchKeyMinutes(label string, rows []activity.KeyMinutes) {
printLabeledKeyMinutes(label, rows, activity.BranchKeyLabel)
}

func printLabeledKeyMinutes(
label string,
rows []activity.KeyMinutes,
labelKey func(string) string,
) {
fmt.Printf("%s (top 5):\n", label)
if len(rows) == 0 {
fmt.Println(" (none)")
Expand All @@ -238,14 +243,17 @@ func printKeyMinutes(label string, rows []activity.KeyMinutes) {
}
w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
for _, row := range topKeyMinutes(rows, 5) {
key := row.Key
if labelKey != nil {
key = labelKey(key)
}
fmt.Fprintf(w, " %s\t%.1f min\n",
sanitizeTerminal(row.Key), row.AgentMinutes)
sanitizeTerminal(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 {
Expand All @@ -265,7 +273,6 @@ func printActivitySessions(rows []activity.SessionRow) {
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)]
}
Expand All @@ -284,8 +291,6 @@ func fmtRangeBound(ts string, loc *time.Location) string {
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 "—"
Expand Down
1 change: 1 addition & 0 deletions frontend/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,7 @@
"activity_project": "Project",
"activity_model": "Model",
"activity_agent": "Agent",
"activity_branch": "Branch",
"activity_min_unit": " min",
"activity_int_auto_split": "int {int} / auto {auto}",
"activity_breakdown": "Breakdown",
Expand Down
1 change: 1 addition & 0 deletions frontend/messages/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -757,6 +757,7 @@
"activity_project": "项目",
"activity_model": "模型",
"activity_agent": "代理",
"activity_branch": "分支",
"activity_min_unit": " 分钟",
"activity_int_auto_split": "交互 {int} / 自动 {auto}",
"activity_breakdown": "细分",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/api/generated/models/ActivityReport.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 49 additions & 2 deletions frontend/src/lib/api/types/activity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,55 @@ import type {
ActivityKeyMinutes,
} from "../generated/index";

export type Report = ActivityReport;
export type Bucket = ActivityBucket;
export type ReportInterval = ActivityReportInterval;
export type SessionRow = ActivitySessionRow;
export type SessionRow = Omit<ActivitySessionRow, "models"> & {
models: string[] | null;
};
export type KeyMinutes = ActivityKeyMinutes;

export type Report = Omit<
ActivityReport,
| "buckets"
| "by_agent"
| "by_branch"
| "by_model"
| "by_project"
| "by_session"
| "intervals"
> & {
buckets: Bucket[] | null;
by_agent: KeyMinutes[] | null;
by_branch: KeyMinutes[] | null;
by_model: KeyMinutes[] | null;
by_project: KeyMinutes[] | null;
by_session: SessionRow[] | null;
intervals: ReportInterval[] | null;
};

function generatedRows<T>(rows: unknown): T[] | null {
if (!Array.isArray(rows)) return null;
return rows as T[];
}

function sessionRowsFromGenerated(rows: unknown): SessionRow[] | null {
const sessions = generatedRows<ActivitySessionRow>(rows);
if (sessions === null) return null;
return sessions.map((session) => ({
...session,
models: generatedRows<string>(session.models),
}));
}

export function activityReportFromGenerated(report: ActivityReport): Report {
return {
...report,
buckets: generatedRows<Bucket>(report.buckets),
by_agent: generatedRows<KeyMinutes>(report.by_agent),
by_branch: generatedRows<KeyMinutes>(report.by_branch),
by_model: generatedRows<KeyMinutes>(report.by_model),
by_project: generatedRows<KeyMinutes>(report.by_project),
by_session: sessionRowsFromGenerated(report.by_session),
intervals: generatedRows<ReportInterval>(report.intervals),
};
}
70 changes: 37 additions & 33 deletions frontend/src/lib/components/activity/Breakdowns.svelte
Original file line number Diff line number Diff line change
@@ -1,65 +1,63 @@
<script lang="ts">
import { m } from "../../i18n/index.js";
import type { Report } from "../../api/types.js";
import type { ActivityKeyMinutes } from "../../api/generated/index";
import type { KeyMinutes, Report } from "../../api/types.js";
import { branchTokenLabel } from "../../branchFilters.js";

let { report }: { report: Report } = $props();

type Metric = "minutes" | "cost";
let metric = $state<Metric>("minutes");

// by_* fields are typed `any[] | null` by the codegen; cast each
// to the generated element model for field-level type safety.
function asKeyMinutes(arr: any[] | null): ActivityKeyMinutes[] {
return (arr ?? []) as ActivityKeyMinutes[];
}

function rowValue(row: ActivityKeyMinutes): number {
function rowValue(row: KeyMinutes): number {
return metric === "cost" ? row.cost : row.agent_minutes;
}

// Per-row automation split for the active metric. Interactive + automated
// sum to rowValue, so the two bar segments stack to the full bar width.
function interactiveValue(row: ActivityKeyMinutes): number {
function interactiveValue(row: KeyMinutes): number {
return metric === "cost"
? row.interactive_cost
: row.interactive_agent_minutes;
}

function automatedValue(row: ActivityKeyMinutes): number {
function automatedValue(row: KeyMinutes): number {
return metric === "cost" ? row.automated_cost : row.automated_agent_minutes;
}

// Rank by the selected metric and drop rows that are zero for it: an untimed
// cost-only row contributes nothing to the minutes view (and would otherwise
// render as an empty "0" bar), and a zero-cost row drops from the cost view.
// The backend pre-sorts by minutes, so re-sort for the cost view.
function rankedRows(arr: any[] | null): ActivityKeyMinutes[] {
return asKeyMinutes(arr)
function rankedRows(arr: KeyMinutes[] | null): KeyMinutes[] {
return (arr ?? [])
.filter((r) => rowValue(r) > 0)
.sort((a, b) => rowValue(b) - rowValue(a));
}

const byProject = $derived(rankedRows(report.by_project));
const byModel = $derived(rankedRows(report.by_model));
const byAgent = $derived(rankedRows(report.by_agent));
const byBranch = $derived(rankedRows(report.by_branch));
const noBranchLabel = $derived(m.shared_no_branch());

interface Panel {
title: string;
rows: ActivityKeyMinutes[];
rows: KeyMinutes[];
label: (key: string) => string;
}

function identityLabel(key: string): string {
return key;
}

function branchDisplayLabel(key: string): string {
return branchTokenLabel(key, noBranchLabel);
}

const panels = $derived.by((): Panel[] => [
{ title: m.activity_project(), rows: byProject },
{ title: m.activity_model(), rows: byModel },
{ title: m.activity_agent(), rows: byAgent },
{ title: m.activity_project(), rows: byProject, label: identityLabel },
{ title: m.activity_model(), rows: byModel, label: identityLabel },
{ title: m.activity_agent(), rows: byAgent, label: identityLabel },
{ title: m.activity_branch(), rows: byBranch, label: branchDisplayLabel },
]);

function maxValue(rows: ActivityKeyMinutes[]): number {
function maxValue(rows: KeyMinutes[]): number {
if (rows.length === 0) return 1;
const m = Math.max(...rows.map(rowValue));
// Fall back to 1 only when the max is non-positive, so the largest bar
// reaches 100% even when every value is under one unit.
return m > 0 ? m : 1;
}

Expand All @@ -75,7 +73,7 @@
return `$${v.toFixed(2)}`;
}

function fmtValue(row: ActivityKeyMinutes): string {
function fmtValue(row: KeyMinutes): string {
return metric === "cost" ? fmtCost(row.cost) : fmtMinutes(row.agent_minutes);
}

Expand All @@ -90,21 +88,26 @@

let tooltip = $state<{ x: number; y: number; text: string } | null>(null);

function sumValue(rows: ActivityKeyMinutes[]): number {
function sumValue(rows: KeyMinutes[]): number {
let total = 0;
for (const r of rows) total += rowValue(r);
return total;
}

function showTip(e: MouseEvent, row: ActivityKeyMinutes, total: number) {
function showTip(
e: MouseEvent,
row: KeyMinutes,
total: number,
label: string,
) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const pct = total > 0 ? Math.round((rowValue(row) / total) * 100) : 0;
const unit = metric === "cost" ? "" : m.activity_min_unit();
const split = m.activity_int_auto_split({ int: fmtSeg(interactiveValue(row)), auto: fmtSeg(automatedValue(row)) });
tooltip = {
x: rect.left + rect.width / 2,
y: rect.top - 4,
text: `${row.key} · ${fmtValue(row)}${unit} · ${pct}% · ${split}`,
text: `${label} · ${fmtValue(row)}${unit} · ${pct}% · ${split}`,
};
}

Expand Down Expand Up @@ -157,14 +160,15 @@
{#if panel.rows.length > 0}
<div class="bar-list">
{#each panel.rows as row (row.key)}
{@const label = panel.label(row.key)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="bar-row"
onmouseenter={(e) => showTip(e, row, total)}
onmouseenter={(e) => showTip(e, row, total, label)}
onmouseleave={hideTip}
>
<span class="bar-label" title={row.key}>
{truncate(row.key, 22)}
<span class="bar-label" title={label}>
{truncate(label, 22)}
</span>
<div class="bar-track">
<div
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/components/activity/Breakdowns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ function makeReport(): Report {
],
by_model: [],
by_agent: [],
by_branch: [],
by_session: [],
intervals: [],
} as Report;
Expand Down
Loading
Loading