diff --git a/apps/web/features/runtimes/components/runtimes-page.tsx b/apps/web/features/runtimes/components/runtimes-page.tsx index f3dae8c0..337453a5 100644 --- a/apps/web/features/runtimes/components/runtimes-page.tsx +++ b/apps/web/features/runtimes/components/runtimes-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback, useRef, useMemo } from "react"; import { Monitor, Cloud, @@ -13,8 +13,29 @@ import { XCircle, Zap, } from "lucide-react"; +import { + AreaChart, + Area, + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + PieChart, + Pie, + Cell, + Label, +} from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + type ChartConfig, +} from "@/components/ui/chart"; import { useDefaultLayout } from "react-resizable-panels"; -import type { AgentRuntime, RuntimeUsage, RuntimePingStatus } from "@/shared/types"; +import type { AgentRuntime, RuntimeUsage, RuntimeHourlyActivity, RuntimePingStatus } from "@/shared/types"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -154,6 +175,529 @@ function RuntimeListItem({ // Usage Section // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Chart configs +// --------------------------------------------------------------------------- + +const tokenChartConfig = { + input: { label: "Input", color: "hsl(var(--chart-1))" }, + output: { label: "Output", color: "hsl(var(--chart-2))" }, + cacheRead: { label: "Cache Read", color: "hsl(var(--chart-3))" }, + cacheWrite: { label: "Cache Write", color: "hsl(var(--chart-4))" }, +} satisfies ChartConfig; + +const costChartConfig = { + cost: { label: "Cost", color: "hsl(var(--chart-1))" }, +} satisfies ChartConfig; + +const MODEL_COLORS = [ + "hsl(var(--chart-1))", + "hsl(var(--chart-2))", + "hsl(var(--chart-3))", + "hsl(var(--chart-4))", + "hsl(var(--chart-5))", +]; + +// --------------------------------------------------------------------------- +// Data aggregation helpers +// --------------------------------------------------------------------------- + +interface DailyTokenData { + date: string; + label: string; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +} + +interface DailyCostData { + date: string; + label: string; + cost: number; +} + +interface ModelDistribution { + model: string; + tokens: number; + cost: number; +} + +function aggregateByDate(usage: RuntimeUsage[]): { + dailyTokens: DailyTokenData[]; + dailyCost: DailyCostData[]; + modelDist: ModelDistribution[]; +} { + // Aggregate tokens by date + const dateMap = new Map>(); + const costMap = new Map(); + const modelMap = new Map(); + + for (const u of usage) { + const existing = dateMap.get(u.date) ?? { + date: u.date, + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + }; + existing.input += u.input_tokens; + existing.output += u.output_tokens; + existing.cacheRead += u.cache_read_tokens; + existing.cacheWrite += u.cache_write_tokens; + dateMap.set(u.date, existing); + + const dayCost = (costMap.get(u.date) ?? 0) + estimateCost(u); + costMap.set(u.date, dayCost); + + const modelName = u.model || u.provider; + const m = modelMap.get(modelName) ?? { tokens: 0, cost: 0 }; + m.tokens += u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens; + m.cost += estimateCost(u); + modelMap.set(modelName, m); + } + + const formatLabel = (d: string) => { + const date = new Date(d + "T00:00:00"); + return `${date.getMonth() + 1}/${date.getDate()}`; + }; + + const dailyTokens = [...dateMap.values()] + .sort((a, b) => a.date.localeCompare(b.date)) + .map((d) => ({ ...d, label: formatLabel(d.date) })); + + const dailyCost = [...costMap.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([date, cost]) => ({ date, label: formatLabel(date), cost: Math.round(cost * 100) / 100 })); + + const modelDist = [...modelMap.entries()] + .map(([model, data]) => ({ model, ...data })) + .sort((a, b) => b.tokens - a.tokens); + + return { dailyTokens, dailyCost, modelDist }; +} + +// --------------------------------------------------------------------------- +// Chart Components +// --------------------------------------------------------------------------- + +function DailyTokenChart({ data }: { data: DailyTokenData[] }) { + return ( +
+

Daily Token Usage

+ + + + + formatTokens(v)} + width={50} + /> + + typeof value === "number" ? formatTokens(value) : String(value) + } + /> + } + /> + } /> + + + + + + +
+ ); +} + +function DailyCostChart({ data }: { data: DailyCostData[] }) { + if (data.every((d) => d.cost === 0)) return null; + + return ( +
+

Daily Estimated Cost

+ + + + + `$${v}`} + width={50} + /> + + typeof value === "number" ? `$${value.toFixed(2)}` : String(value) + } + /> + } + /> + + + +
+ ); +} + +function ModelDistributionChart({ data }: { data: ModelDistribution[] }) { + if (data.length === 0) return null; + + const totalTokens = data.reduce((sum, d) => sum + d.tokens, 0); + const chartConfig = Object.fromEntries( + data.map((d, i) => [ + d.model, + { label: d.model, color: MODEL_COLORS[i % MODEL_COLORS.length] }, + ]), + ) satisfies ChartConfig; + + return ( +
+

Token Usage by Model

+ + + + typeof value === "number" ? formatTokens(value) : String(value) + } + nameKey="model" + /> + } + /> + + {data.map((entry, i) => ( + + ))} + + + + {/* Model legend with cost */} +
+ {data.map((d, i) => ( +
+
+
+ {d.model} +
+
+ {formatTokens(d.tokens)} + {d.cost > 0 && ${d.cost.toFixed(2)}} +
+
+ ))} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Activity Heatmap (GitHub-style) +// --------------------------------------------------------------------------- + +const HEATMAP_WEEKS = 13; +const CELL_SIZE = 11; +const CELL_GAP = 2; +const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""]; + +function getHeatmapColor(level: number): string { + // 5 levels: 0=empty, 1-4=increasing intensity + const colors = [ + "var(--color-muted, hsl(var(--muted)))", + "hsl(var(--chart-3) / 0.3)", + "hsl(var(--chart-3) / 0.5)", + "hsl(var(--chart-3) / 0.75)", + "hsl(var(--chart-3) / 1)", + ]; + return colors[level] ?? colors[0]!; +} + +function ActivityHeatmap({ usage }: { usage: RuntimeUsage[] }) { + const { cells, monthLabels } = useMemo(() => { + // Build a map of date -> total tokens + const dateTokens = new Map(); + for (const u of usage) { + const total = u.input_tokens + u.output_tokens + u.cache_read_tokens + u.cache_write_tokens; + dateTokens.set(u.date, (dateTokens.get(u.date) ?? 0) + total); + } + + // Generate all dates for the last HEATMAP_WEEKS weeks + const today = new Date(); + const todayDay = today.getDay(); // 0=Sun + // Start from the beginning of the week, HEATMAP_WEEKS weeks ago + const startOffset = todayDay + (HEATMAP_WEEKS - 1) * 7; + const startDate = new Date(today); + startDate.setDate(today.getDate() - startOffset); + + const allCells: { date: string; dayOfWeek: number; week: number; tokens: number }[] = []; + const d = new Date(startDate); + for (let i = 0; i <= startOffset; i++) { + const dateStr = d.toISOString().slice(0, 10); + const dayOfWeek = d.getDay(); // 0=Sun .. 6=Sat + const week = Math.floor(i / 7); + allCells.push({ date: dateStr, dayOfWeek, week, tokens: dateTokens.get(dateStr) ?? 0 }); + d.setDate(d.getDate() + 1); + } + + // Compute intensity levels (quantiles) + const nonZero = allCells.filter((c) => c.tokens > 0).map((c) => c.tokens); + nonZero.sort((a, b) => a - b); + const getLevel = (tokens: number) => { + if (tokens === 0) return 0; + if (nonZero.length <= 1) return 4; + const p = nonZero.indexOf(tokens) / (nonZero.length - 1); + if (p <= 0.25) return 1; + if (p <= 0.5) return 2; + if (p <= 0.75) return 3; + return 4; + }; + + const cellsWithLevel = allCells.map((c) => ({ ...c, level: getLevel(c.tokens) })); + + // Month labels: find the first day of each month that appears + const months: { label: string; week: number }[] = []; + let lastMonth = -1; + for (const c of cellsWithLevel) { + const month = new Date(c.date + "T00:00:00").getMonth(); + if (month !== lastMonth && c.dayOfWeek === 0) { + months.push({ + label: new Date(c.date + "T00:00:00").toLocaleString("en", { month: "short" }), + week: c.week, + }); + lastMonth = month; + } + } + + return { cells: cellsWithLevel, monthLabels: months }; + }, [usage]); + + const labelWidth = 28; + const svgWidth = labelWidth + HEATMAP_WEEKS * (CELL_SIZE + CELL_GAP); + const svgHeight = 14 + 7 * (CELL_SIZE + CELL_GAP); + + return ( +
+

Activity

+
+ + {/* Month labels */} + {monthLabels.map((m) => ( + + {m.label} + + ))} + {/* Day labels */} + {DAY_LABELS.map((label, i) => + label ? ( + + {label} + + ) : null, + )} + {/* Cells */} + {cells.map((c) => ( + + + {c.date}: {c.tokens > 0 ? formatTokens(c.tokens) + " tokens" : "No activity"} + + + ))} + +
+ {/* Legend */} +
+ Less + {[0, 1, 2, 3, 4].map((level) => ( +
+ ))} + More +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Hourly Activity Distribution +// --------------------------------------------------------------------------- + +const hourlyChartConfig = { + count: { label: "Tasks", color: "hsl(var(--chart-2))" }, +} satisfies ChartConfig; + +function HourlyActivityChart({ runtimeId }: { runtimeId: string }) { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + setLoading(true); + api + .getRuntimeTaskActivity(runtimeId) + .then(setData) + .catch(() => setData([])) + .finally(() => setLoading(false)); + }, [runtimeId]); + + // Fill all 24 hours + const chartData = useMemo(() => { + const map = new Map(data.map((d) => [d.hour, d.count])); + return Array.from({ length: 24 }, (_, i) => ({ + hour: i, + label: `${i.toString().padStart(2, "0")}:00`, + count: map.get(i) ?? 0, + })); + }, [data]); + + const hasData = chartData.some((d) => d.count > 0); + + return ( +
+

Hourly Distribution

+ {loading ? ( +
+ Loading... +
+ ) : !hasData ? ( +
+ +

No task data yet

+
+ ) : ( + + + + + + } /> + + + + )} +
+ ); +} + +// --------------------------------------------------------------------------- +// Usage Section +// --------------------------------------------------------------------------- + function UsageSection({ runtimeId }: { runtimeId: string }) { const [usage, setUsage] = useState([]); const [loading, setLoading] = useState(true); @@ -161,7 +705,7 @@ function UsageSection({ runtimeId }: { runtimeId: string }) { useEffect(() => { setLoading(true); api - .getRuntimeUsage(runtimeId, { days: 30 }) + .getRuntimeUsage(runtimeId, { days: 90 }) .then(setUsage) .catch(() => setUsage([])) .finally(() => setLoading(false)); @@ -184,8 +728,14 @@ function UsageSection({ runtimeId }: { runtimeId: string }) { ); } - // Compute totals - const totals = usage.reduce( + // Filter last 30 days for summary / detail charts + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + const cutoff = thirtyDaysAgo.toISOString().slice(0, 10); + const recent = usage.filter((u) => u.date >= cutoff); + + // Compute totals (30d) + const totals = recent.reduce( (acc, u) => ({ input: acc.input + u.input_tokens, output: acc.output + u.output_tokens, @@ -196,9 +746,11 @@ function UsageSection({ runtimeId }: { runtimeId: string }) { { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, ); + const { dailyTokens, dailyCost, modelDist } = aggregateByDate(recent); + // Group by date for the table const byDate = new Map(); - for (const u of usage) { + for (const u of recent) { const existing = byDate.get(u.date) ?? []; existing.push(u); byDate.set(u.date, existing); @@ -225,6 +777,20 @@ function UsageSection({ runtimeId }: { runtimeId: string }) {
)} + {/* Heatmap + Hourly — 2-col on wide screens */} +
+ + +
+ + {/* Token & Cost charts — 2-col on wide screens */} +
+ + +
+ + + {/* Daily breakdown table */}
diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index 80b915fa..dbde3195 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -29,6 +29,7 @@ import type { CreatePersonalAccessTokenRequest, CreatePersonalAccessTokenResponse, RuntimeUsage, + RuntimeHourlyActivity, RuntimePing, TimelineEntry, } from "@/shared/types"; @@ -278,6 +279,10 @@ export class ApiClient { return this.fetch(`/api/runtimes/${runtimeId}/usage?${search}`); } + async getRuntimeTaskActivity(runtimeId: string): Promise { + return this.fetch(`/api/runtimes/${runtimeId}/activity`); + } + async pingRuntime(runtimeId: string): Promise { return this.fetch(`/api/runtimes/${runtimeId}/ping`, { method: "POST" }); } diff --git a/apps/web/shared/types/agent.ts b/apps/web/shared/types/agent.ts index 31d83d91..23fcc924 100644 --- a/apps/web/shared/types/agent.ts +++ b/apps/web/shared/types/agent.ts @@ -166,3 +166,8 @@ export interface RuntimeUsage { cache_read_tokens: number; cache_write_tokens: number; } + +export interface RuntimeHourlyActivity { + hour: number; + count: number; +} diff --git a/apps/web/shared/types/index.ts b/apps/web/shared/types/index.ts index 9ea24796..7bc3d362 100644 --- a/apps/web/shared/types/index.ts +++ b/apps/web/shared/types/index.ts @@ -18,6 +18,7 @@ export type { UpdateSkillRequest, SetAgentSkillsRequest, RuntimeUsage, + RuntimeHourlyActivity, RuntimePing, RuntimePingStatus, } from "./agent"; diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 12a6ea57..dc488e83 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -163,6 +163,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route r.Route("/api/runtimes", func(r chi.Router) { r.Get("/", h.ListAgentRuntimes) r.Get("/{runtimeId}/usage", h.GetRuntimeUsage) + r.Get("/{runtimeId}/activity", h.GetRuntimeTaskActivity) r.Post("/{runtimeId}/ping", h.InitiatePing) r.Get("/{runtimeId}/ping/{pingId}", h.GetPing) }) diff --git a/server/internal/handler/runtime.go b/server/internal/handler/runtime.go index eeb61b5a..bccf4051 100644 --- a/server/internal/handler/runtime.go +++ b/server/internal/handler/runtime.go @@ -159,6 +159,39 @@ func (h *Handler) GetRuntimeUsage(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } +// GetRuntimeTaskActivity returns hourly task activity distribution for a runtime. +func (h *Handler) GetRuntimeTaskActivity(w http.ResponseWriter, r *http.Request) { + runtimeID := chi.URLParam(r, "runtimeId") + + rt, err := h.Queries.GetAgentRuntime(r.Context(), parseUUID(runtimeID)) + if err != nil { + writeError(w, http.StatusNotFound, "runtime not found") + return + } + + if _, ok := h.requireWorkspaceMember(w, r, uuidToString(rt.WorkspaceID), "runtime not found"); !ok { + return + } + + rows, err := h.Queries.GetRuntimeTaskHourlyActivity(r.Context(), parseUUID(runtimeID)) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to get task activity") + return + } + + type HourlyActivity struct { + Hour int `json:"hour"` + Count int `json:"count"` + } + + resp := make([]HourlyActivity, len(rows)) + for i, row := range rows { + resp[i] = HourlyActivity{Hour: int(row.Hour), Count: int(row.Count)} + } + + writeJSON(w, http.StatusOK, resp) +} + func (h *Handler) ListAgentRuntimes(w http.ResponseWriter, r *http.Request) { workspaceID := resolveWorkspaceID(r) if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok { diff --git a/server/pkg/db/generated/runtime_usage.sql.go b/server/pkg/db/generated/runtime_usage.sql.go index f01354ce..9943611b 100644 --- a/server/pkg/db/generated/runtime_usage.sql.go +++ b/server/pkg/db/generated/runtime_usage.sql.go @@ -11,6 +11,39 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const getRuntimeTaskHourlyActivity = `-- name: GetRuntimeTaskHourlyActivity :many +SELECT EXTRACT(HOUR FROM started_at)::int AS hour, COUNT(*)::int AS count +FROM agent_task_queue +WHERE runtime_id = $1 AND started_at IS NOT NULL +GROUP BY hour +ORDER BY hour +` + +type GetRuntimeTaskHourlyActivityRow struct { + Hour int32 `json:"hour"` + Count int32 `json:"count"` +} + +func (q *Queries) GetRuntimeTaskHourlyActivity(ctx context.Context, runtimeID pgtype.UUID) ([]GetRuntimeTaskHourlyActivityRow, error) { + rows, err := q.db.Query(ctx, getRuntimeTaskHourlyActivity, runtimeID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []GetRuntimeTaskHourlyActivityRow{} + for rows.Next() { + var i GetRuntimeTaskHourlyActivityRow + if err := rows.Scan(&i.Hour, &i.Count); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getRuntimeUsageSummary = `-- name: GetRuntimeUsageSummary :many SELECT provider, model, SUM(input_tokens)::bigint AS total_input_tokens, diff --git a/server/pkg/db/queries/runtime_usage.sql b/server/pkg/db/queries/runtime_usage.sql index c2be6a78..6db8e93d 100644 --- a/server/pkg/db/queries/runtime_usage.sql +++ b/server/pkg/db/queries/runtime_usage.sql @@ -25,3 +25,10 @@ FROM runtime_usage WHERE runtime_id = $1 GROUP BY provider, model ORDER BY provider, model; + +-- name: GetRuntimeTaskHourlyActivity :many +SELECT EXTRACT(HOUR FROM started_at)::int AS hour, COUNT(*)::int AS count +FROM agent_task_queue +WHERE runtime_id = $1 AND started_at IS NOT NULL +GROUP BY hour +ORDER BY hour;