diff --git a/apps/web/app/(dashboard)/agents/page.tsx b/apps/web/app/(dashboard)/agents/page.tsx index 63762143..985cb1dc 100644 --- a/apps/web/app/(dashboard)/agents/page.tsx +++ b/apps/web/app/(dashboard)/agents/page.tsx @@ -65,6 +65,7 @@ import { Label } from "@/components/ui/label"; import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; +import { useRuntimeStore } from "@/features/runtimes"; // --------------------------------------------------------------------------- @@ -1130,21 +1131,15 @@ export default function AgentsPage() { const refreshAgents = useWorkspaceStore((s) => s.refreshAgents); const [selectedId, setSelectedId] = useState(""); const [showCreate, setShowCreate] = useState(false); - const [runtimes, setRuntimes] = useState([]); + const runtimes = useRuntimeStore((s) => s.runtimes); + const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes); const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: "multica_agents_layout", }); useEffect(() => { - if (!workspace) { - setRuntimes([]); - return; - } - api - .listRuntimes({ workspace_id: workspace.id }) - .then(setRuntimes) - .catch(() => setRuntimes([])); - }, [workspace]); + if (workspace) fetchRuntimes(); + }, [workspace, fetchRuntimes]); // Select first agent on initial load useEffect(() => { diff --git a/apps/web/features/runtimes/components/charts/activity-heatmap.tsx b/apps/web/features/runtimes/components/charts/activity-heatmap.tsx new file mode 100644 index 00000000..63d1b42c --- /dev/null +++ b/apps/web/features/runtimes/components/charts/activity-heatmap.tsx @@ -0,0 +1,161 @@ +import { useMemo } from "react"; +import type { RuntimeUsage } from "@/shared/types"; +import { formatTokens } from "../../utils"; + +const HEATMAP_WEEKS = 13; +const CELL_SIZE = 11; +const CELL_GAP = 2; +const DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""]; + +function getHeatmapColor(level: number): string { + 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]!; +} + +export function ActivityHeatmap({ usage }: { usage: RuntimeUsage[] }) { + const { cells, monthLabels } = useMemo(() => { + 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); + } + + const today = new Date(); + const todayDay = today.getDay(); + 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(); + const week = Math.floor(i / 7); + allCells.push({ + date: dateStr, + dayOfWeek, + week, + tokens: dateTokens.get(dateStr) ?? 0, + }); + d.setDate(d.getDate() + 1); + } + + 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), + })); + + 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

+
+ + {monthLabels.map((m) => ( + + {m.label} + + ))} + {DAY_LABELS.map((label, i) => + label ? ( + + {label} + + ) : null, + )} + {cells.map((c) => ( + + + {c.date}:{" "} + {c.tokens > 0 + ? formatTokens(c.tokens) + " tokens" + : "No activity"} + + + ))} + +
+ {/* Legend */} +
+ Less + {[0, 1, 2, 3, 4].map((level) => ( +
+ ))} + More +
+
+ ); +} diff --git a/apps/web/features/runtimes/components/charts/daily-cost-chart.tsx b/apps/web/features/runtimes/components/charts/daily-cost-chart.tsx new file mode 100644 index 00000000..d96718fd --- /dev/null +++ b/apps/web/features/runtimes/components/charts/daily-cost-chart.tsx @@ -0,0 +1,57 @@ +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, +} from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import type { DailyCostData } from "../../utils"; + +const costChartConfig = { + cost: { label: "Cost", color: "hsl(var(--chart-1))" }, +} satisfies ChartConfig; + +export 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) + } + /> + } + /> + + + +
+ ); +} diff --git a/apps/web/features/runtimes/components/charts/daily-token-chart.tsx b/apps/web/features/runtimes/components/charts/daily-token-chart.tsx new file mode 100644 index 00000000..a5bb6c8b --- /dev/null +++ b/apps/web/features/runtimes/components/charts/daily-token-chart.tsx @@ -0,0 +1,93 @@ +import { + AreaChart, + Area, + XAxis, + YAxis, + CartesianGrid, +} from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + ChartLegend, + ChartLegendContent, + type ChartConfig, +} from "@/components/ui/chart"; +import type { DailyTokenData } from "../../utils"; +import { formatTokens } from "../../utils"; + +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; + +export function DailyTokenChart({ data }: { data: DailyTokenData[] }) { + return ( +
+

Daily Token Usage

+ + + + + formatTokens(v)} + width={50} + /> + + typeof value === "number" ? formatTokens(value) : String(value) + } + /> + } + /> + } /> + + + + + + +
+ ); +} diff --git a/apps/web/features/runtimes/components/charts/hourly-activity-chart.tsx b/apps/web/features/runtimes/components/charts/hourly-activity-chart.tsx new file mode 100644 index 00000000..5fd1f68e --- /dev/null +++ b/apps/web/features/runtimes/components/charts/hourly-activity-chart.tsx @@ -0,0 +1,85 @@ +import { useState, useEffect, useMemo } from "react"; +import { BarChart3 } from "lucide-react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, +} from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import { api } from "@/shared/api"; +import type { RuntimeHourlyActivity } from "@/shared/types"; + +const hourlyChartConfig = { + count: { label: "Tasks", color: "hsl(var(--chart-2))" }, +} satisfies ChartConfig; + +export 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]); + + 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

+
+ ) : ( + + + + + + } /> + + + + )} +
+ ); +} diff --git a/apps/web/features/runtimes/components/charts/index.ts b/apps/web/features/runtimes/components/charts/index.ts new file mode 100644 index 00000000..cc1ec80a --- /dev/null +++ b/apps/web/features/runtimes/components/charts/index.ts @@ -0,0 +1,5 @@ +export { DailyTokenChart } from "./daily-token-chart"; +export { DailyCostChart } from "./daily-cost-chart"; +export { ModelDistributionChart } from "./model-distribution-chart"; +export { ActivityHeatmap } from "./activity-heatmap"; +export { HourlyActivityChart } from "./hourly-activity-chart"; diff --git a/apps/web/features/runtimes/components/charts/model-distribution-chart.tsx b/apps/web/features/runtimes/components/charts/model-distribution-chart.tsx new file mode 100644 index 00000000..b8a7f1c0 --- /dev/null +++ b/apps/web/features/runtimes/components/charts/model-distribution-chart.tsx @@ -0,0 +1,99 @@ +import { PieChart, Pie, Cell, Label } from "recharts"; +import { + ChartContainer, + ChartTooltip, + ChartTooltipContent, + type ChartConfig, +} from "@/components/ui/chart"; +import type { ModelDistribution } from "../../utils"; +import { formatTokens } from "../../utils"; + +const MODEL_COLORS = [ + "hsl(var(--chart-1))", + "hsl(var(--chart-2))", + "hsl(var(--chart-3))", + "hsl(var(--chart-4))", + "hsl(var(--chart-5))", +]; + +export 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)}} +
+
+ ))} +
+
+ ); +} diff --git a/apps/web/features/runtimes/components/ping-section.tsx b/apps/web/features/runtimes/components/ping-section.tsx new file mode 100644 index 00000000..10150477 --- /dev/null +++ b/apps/web/features/runtimes/components/ping-section.tsx @@ -0,0 +1,120 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { Loader2, CheckCircle2, XCircle, Zap } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { api } from "@/shared/api"; +import type { RuntimePingStatus } from "@/shared/types"; + +const pingStatusConfig: Record< + RuntimePingStatus, + { label: string; icon: typeof Loader2; color: string } +> = { + pending: { label: "Waiting for daemon...", icon: Loader2, color: "text-muted-foreground" }, + running: { label: "Running test...", icon: Loader2, color: "text-info" }, + completed: { label: "Connected", icon: CheckCircle2, color: "text-success" }, + failed: { label: "Failed", icon: XCircle, color: "text-destructive" }, + timeout: { label: "Timeout", icon: XCircle, color: "text-warning" }, +}; + +export function PingSection({ runtimeId }: { runtimeId: string }) { + const [status, setStatus] = useState(null); + const [output, setOutput] = useState(""); + const [error, setError] = useState(""); + const [durationMs, setDurationMs] = useState(null); + const [testing, setTesting] = useState(false); + const pollRef = useRef | null>(null); + + const cleanup = useCallback(() => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }, []); + + useEffect(() => cleanup, [cleanup]); + + const handleTest = async () => { + cleanup(); + setTesting(true); + setStatus("pending"); + setOutput(""); + setError(""); + setDurationMs(null); + + try { + const ping = await api.pingRuntime(runtimeId); + + pollRef.current = setInterval(async () => { + try { + const result = await api.getPingResult(runtimeId, ping.id); + setStatus(result.status as RuntimePingStatus); + + if (result.status === "completed") { + setOutput(result.output ?? ""); + setDurationMs(result.duration_ms ?? null); + setTesting(false); + cleanup(); + } else if (result.status === "failed" || result.status === "timeout") { + setError(result.error ?? "Unknown error"); + setDurationMs(result.duration_ms ?? null); + setTesting(false); + cleanup(); + } + } catch { + // ignore poll errors + } + }, 2000); + } catch { + setStatus("failed"); + setError("Failed to initiate test"); + setTesting(false); + } + }; + + const config = status ? pingStatusConfig[status] : null; + const Icon = config?.icon; + const isActive = status === "pending" || status === "running"; + + return ( +
+
+ + + {config && Icon && ( + + + {config.label} + {durationMs != null && ( + + ({(durationMs / 1000).toFixed(1)}s) + + )} + + )} +
+ + {status === "completed" && output && ( +
+
{output}
+
+ )} + + {(status === "failed" || status === "timeout") && error && ( +
+

{error}

+
+ )} +
+ ); +} diff --git a/apps/web/features/runtimes/components/runtime-detail.tsx b/apps/web/features/runtimes/components/runtime-detail.tsx new file mode 100644 index 00000000..4e66f1cc --- /dev/null +++ b/apps/web/features/runtimes/components/runtime-detail.tsx @@ -0,0 +1,158 @@ +"use client"; + +import { useState } from "react"; +import { Trash2 } from "lucide-react"; +import type { AgentRuntime } from "@/shared/types"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { formatLastSeen } from "../utils"; +import { useRuntimeStore } from "../store"; +import { RuntimeModeIcon, StatusBadge, InfoField } from "./shared"; +import { PingSection } from "./ping-section"; +import { UsageSection } from "./usage-section"; + +export function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { + const [showDelete, setShowDelete] = useState(false); + const [deleting, setDeleting] = useState(false); + const deleteRuntime = useRuntimeStore((s) => s.deleteRuntime); + + const handleDelete = async () => { + setDeleting(true); + try { + await deleteRuntime(runtime.id); + } finally { + setDeleting(false); + setShowDelete(false); + } + }; + + return ( +
+ {/* Header */} +
+
+
+ +
+
+

{runtime.name}

+
+
+
+ + +
+
+ + {/* Content */} +
+ {/* Info grid */} +
+ + + + + {runtime.device_info && ( + + )} + {runtime.daemon_id && ( + + )} +
+ + {/* Connection Test */} +
+

+ Connection Test +

+ +
+ + {/* Usage */} +
+

+ Token Usage +

+ +
+ + {/* Metadata */} + {runtime.metadata && Object.keys(runtime.metadata).length > 0 && ( +
+

+ Metadata +

+
+
+                {JSON.stringify(runtime.metadata, null, 2)}
+              
+
+
+ )} + + {/* Timestamps */} +
+ + +
+
+ + {/* Delete confirmation dialog */} + + + + Delete Runtime + + Are you sure you want to delete "{runtime.name}"? This + will remove the runtime and its usage data. This action cannot be + undone. + + + + + + + + +
+ ); +} diff --git a/apps/web/features/runtimes/components/runtime-list.tsx b/apps/web/features/runtimes/components/runtime-list.tsx new file mode 100644 index 00000000..07952458 --- /dev/null +++ b/apps/web/features/runtimes/components/runtime-list.tsx @@ -0,0 +1,89 @@ +import { Server } from "lucide-react"; +import type { AgentRuntime } from "@/shared/types"; +import { RuntimeModeIcon } from "./shared"; + +function RuntimeListItem({ + runtime, + isSelected, + onClick, +}: { + runtime: AgentRuntime; + isSelected: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export function RuntimeList({ + runtimes, + selectedId, + onSelect, +}: { + runtimes: AgentRuntime[]; + selectedId: string; + onSelect: (id: string) => void; +}) { + return ( +
+
+

Runtimes

+ + {runtimes.filter((r) => r.status === "online").length}/ + {runtimes.length} online + +
+ {runtimes.length === 0 ? ( +
+ +

+ No runtimes registered +

+

+ Run{" "} + + multica daemon start + {" "} + to register a local runtime. +

+
+ ) : ( +
+ {runtimes.map((runtime) => ( + onSelect(runtime.id)} + /> + ))} +
+ )} +
+ ); +} diff --git a/apps/web/features/runtimes/components/runtimes-page.tsx b/apps/web/features/runtimes/components/runtimes-page.tsx index 337453a5..f1f83324 100644 --- a/apps/web/features/runtimes/components/runtimes-page.tsx +++ b/apps/web/features/runtimes/components/runtimes-page.tsx @@ -1,1119 +1,44 @@ "use client"; -import { useState, useEffect, useCallback, useRef, useMemo } from "react"; -import { - Monitor, - Cloud, - Wifi, - WifiOff, - Server, - BarChart3, - Loader2, - CheckCircle2, - 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 { useEffect, useCallback } from "react"; +import { Server } from "lucide-react"; import { useDefaultLayout } from "react-resizable-panels"; -import type { AgentRuntime, RuntimeUsage, RuntimeHourlyActivity, RuntimePingStatus } from "@/shared/types"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; import { ResizablePanelGroup, ResizablePanel, ResizableHandle, } from "@/components/ui/resizable"; -import { api } from "@/shared/api"; import { useAuthStore } from "@/features/auth"; import { useWorkspaceStore } from "@/features/workspace"; import { useWSEvent } from "@/features/realtime"; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function formatLastSeen(lastSeenAt: string | null): string { - if (!lastSeenAt) return "Never"; - const diff = Date.now() - new Date(lastSeenAt).getTime(); - if (diff < 60_000) return "Just now"; - if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; - if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; - return `${Math.floor(diff / 86_400_000)}d ago`; -} - -function formatTokens(n: number): string { - if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; - if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; - return n.toLocaleString(); -} - -// Pricing per million tokens (USD) -const MODEL_PRICING: Record = { - "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, - "claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, - "claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, - "claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, -}; - -function estimateCost(usage: RuntimeUsage): number { - // Try to find a matching model in pricing table - const model = usage.model; - let pricing = MODEL_PRICING[model]; - if (!pricing) { - // Try partial match - for (const [key, p] of Object.entries(MODEL_PRICING)) { - if (model.startsWith(key)) { - pricing = p; - break; - } - } - } - if (!pricing) return 0; - - return ( - (usage.input_tokens * pricing.input + - usage.output_tokens * pricing.output + - usage.cache_read_tokens * pricing.cacheRead + - usage.cache_write_tokens * pricing.cacheWrite) / - 1_000_000 - ); -} - -function RuntimeModeIcon({ mode }: { mode: string }) { - return mode === "cloud" ? ( - - ) : ( - - ); -} - -function StatusBadge({ status }: { status: string }) { - const isOnline = status === "online"; - return ( - - {isOnline ? ( - - ) : ( - - )} - {isOnline ? "Online" : "Offline"} - - ); -} - -// --------------------------------------------------------------------------- -// Runtime List Item -// --------------------------------------------------------------------------- - -function RuntimeListItem({ - runtime, - isSelected, - onClick, -}: { - runtime: AgentRuntime; - isSelected: boolean; - onClick: () => void; -}) { - return ( - - ); -} - -// --------------------------------------------------------------------------- -// 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); - - useEffect(() => { - setLoading(true); - api - .getRuntimeUsage(runtimeId, { days: 90 }) - .then(setUsage) - .catch(() => setUsage([])) - .finally(() => setLoading(false)); - }, [runtimeId]); - - if (loading) { - return ( -
Loading usage...
- ); - } - - if (usage.length === 0) { - return ( -
- -

- No usage data yet -

-
- ); - } - - // 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, - cacheRead: acc.cacheRead + u.cache_read_tokens, - cacheWrite: acc.cacheWrite + u.cache_write_tokens, - cost: acc.cost + estimateCost(u), - }), - { 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 recent) { - const existing = byDate.get(u.date) ?? []; - existing.push(u); - byDate.set(u.date, existing); - } - - return ( -
- {/* Summary cards */} -
- - - - -
- - {totals.cost > 0 && ( -
- - Estimated cost (30d):{" "} - - - ${totals.cost.toFixed(2)} - -
- )} - - {/* Heatmap + Hourly — 2-col on wide screens */} -
- - -
- - {/* Token & Cost charts — 2-col on wide screens */} -
- - -
- - - - {/* Daily breakdown table */} -
-
-
Date
-
Model
-
Input
-
Output
-
Cache R
-
Cache W
-
-
- {[...byDate.entries()].map(([date, rows]) => - rows.map((row, i) => ( -
-
{date}
-
{row.model}
-
- {formatTokens(row.input_tokens)} -
-
- {formatTokens(row.output_tokens)} -
-
- {formatTokens(row.cache_read_tokens)} -
-
- {formatTokens(row.cache_write_tokens)} -
-
- )), - )} -
-
-
- ); -} - -function TokenCard({ label, value }: { label: string; value: number }) { - return ( -
-
{label}
-
- {formatTokens(value)} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Connection Test (Ping) -// --------------------------------------------------------------------------- - -const pingStatusConfig: Record< - RuntimePingStatus, - { label: string; icon: typeof Loader2; color: string } -> = { - pending: { label: "Waiting for daemon...", icon: Loader2, color: "text-muted-foreground" }, - running: { label: "Running test...", icon: Loader2, color: "text-info" }, - completed: { label: "Connected", icon: CheckCircle2, color: "text-success" }, - failed: { label: "Failed", icon: XCircle, color: "text-destructive" }, - timeout: { label: "Timeout", icon: XCircle, color: "text-warning" }, -}; - -function PingSection({ runtimeId }: { runtimeId: string }) { - const [status, setStatus] = useState(null); - const [output, setOutput] = useState(""); - const [error, setError] = useState(""); - const [durationMs, setDurationMs] = useState(null); - const [testing, setTesting] = useState(false); - const pollRef = useRef | null>(null); - - const cleanup = useCallback(() => { - if (pollRef.current) { - clearInterval(pollRef.current); - pollRef.current = null; - } - }, []); - - useEffect(() => cleanup, [cleanup]); - - const handleTest = async () => { - cleanup(); - setTesting(true); - setStatus("pending"); - setOutput(""); - setError(""); - setDurationMs(null); - - try { - const ping = await api.pingRuntime(runtimeId); - - // Poll for result every 2 seconds - pollRef.current = setInterval(async () => { - try { - const result = await api.getPingResult(runtimeId, ping.id); - setStatus(result.status as RuntimePingStatus); - - if (result.status === "completed") { - setOutput(result.output ?? ""); - setDurationMs(result.duration_ms ?? null); - setTesting(false); - cleanup(); - } else if (result.status === "failed" || result.status === "timeout") { - setError(result.error ?? "Unknown error"); - setDurationMs(result.duration_ms ?? null); - setTesting(false); - cleanup(); - } - } catch { - // ignore poll errors - } - }, 2000); - } catch { - setStatus("failed"); - setError("Failed to initiate test"); - setTesting(false); - } - }; - - const config = status ? pingStatusConfig[status] : null; - const Icon = config?.icon; - const isActive = status === "pending" || status === "running"; - - return ( -
-
- - - {config && Icon && ( - - - {config.label} - {durationMs != null && ( - - ({(durationMs / 1000).toFixed(1)}s) - - )} - - )} -
- - {status === "completed" && output && ( -
-
{output}
-
- )} - - {(status === "failed" || status === "timeout") && error && ( -
-

{error}

-
- )} -
- ); -} - -// --------------------------------------------------------------------------- -// Runtime Detail -// --------------------------------------------------------------------------- - -function RuntimeDetail({ runtime }: { runtime: AgentRuntime }) { - return ( -
- {/* Header */} -
-
-
- -
-
-

{runtime.name}

-
-
- -
- - {/* Content */} -
- {/* Info grid */} -
- - - - - {runtime.device_info && ( - - )} - {runtime.daemon_id && ( - - )} -
- - {/* Connection Test */} -
-

- Connection Test -

- -
- - {/* Usage */} -
-

- Token Usage (Last 30 Days) -

- -
- - {/* Metadata */} - {runtime.metadata && Object.keys(runtime.metadata).length > 0 && ( -
-

- Metadata -

-
-
-                {JSON.stringify(runtime.metadata, null, 2)}
-              
-
-
- )} - - {/* Timestamps */} -
- - -
-
-
- ); -} - -function InfoField({ - label, - value, - mono, -}: { - label: string; - value: string; - mono?: boolean; -}) { - return ( -
-
{label}
-
- {value} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Page -// --------------------------------------------------------------------------- +import { useRuntimeStore } from "../store"; +import { RuntimeList } from "./runtime-list"; +import { RuntimeDetail } from "./runtime-detail"; export default function RuntimesPage() { const isLoading = useAuthStore((s) => s.isLoading); const workspace = useWorkspaceStore((s) => s.workspace); - const [runtimes, setRuntimes] = useState([]); - const [selectedId, setSelectedId] = useState(""); - const [fetching, setFetching] = useState(true); + const runtimes = useRuntimeStore((s) => s.runtimes); + const selectedId = useRuntimeStore((s) => s.selectedId); + const fetching = useRuntimeStore((s) => s.fetching); + const fetchRuntimes = useRuntimeStore((s) => s.fetchRuntimes); + const setSelectedId = useRuntimeStore((s) => s.setSelectedId); + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ id: "multica_runtimes_layout", }); - const fetchRuntimes = useCallback(async () => { - if (!workspace) return; - try { - const data = await api.listRuntimes({ workspace_id: workspace.id }); - setRuntimes(data); - } finally { - setFetching(false); - } - }, [workspace]); - useEffect(() => { - fetchRuntimes(); - }, [fetchRuntimes]); + if (workspace) fetchRuntimes(); + }, [workspace, fetchRuntimes]); - // Auto-select first runtime - useEffect(() => { - if (runtimes.length > 0 && !selectedId) { - setSelectedId(runtimes[0]!.id); - } - }, [runtimes, selectedId]); - - // Real-time updates + // Re-fetch on daemon register/deregister events. + // Heartbeat events are not broadcast over WS, so no handler needed. const handleDaemonEvent = useCallback(() => { fetchRuntimes(); }, [fetchRuntimes]); useWSEvent("daemon:register", handleDaemonEvent); - useWSEvent("daemon:heartbeat", handleDaemonEvent); const selected = runtimes.find((r) => r.id === selectedId) ?? null; @@ -1132,49 +57,23 @@ export default function RuntimesPage() { defaultLayout={defaultLayout} onLayoutChanged={onLayoutChanged} > - - {/* Left column — runtime list */} -
-
-

Runtimes

- - {runtimes.filter((r) => r.status === "online").length}/ - {runtimes.length} online - -
- {runtimes.length === 0 ? ( -
- -

- No runtimes registered -

-

- Run{" "} - - multica daemon start - {" "} - to register a local runtime. -

-
- ) : ( -
- {runtimes.map((runtime) => ( - setSelectedId(runtime.id)} - /> - ))} -
- )} -
+ + - {/* Right column — runtime detail */} {selected ? ( ) : ( diff --git a/apps/web/features/runtimes/components/shared.tsx b/apps/web/features/runtimes/components/shared.tsx new file mode 100644 index 00000000..bbe2dbee --- /dev/null +++ b/apps/web/features/runtimes/components/shared.tsx @@ -0,0 +1,57 @@ +import { Monitor, Cloud, Wifi, WifiOff } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; + +export function RuntimeModeIcon({ mode }: { mode: string }) { + return mode === "cloud" ? ( + + ) : ( + + ); +} + +export function StatusBadge({ status }: { status: string }) { + const isOnline = status === "online"; + return ( + + {isOnline ? ( + + ) : ( + + )} + {isOnline ? "Online" : "Offline"} + + ); +} + +export function InfoField({ + label, + value, + mono, +}: { + label: string; + value: string; + mono?: boolean; +}) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +export function TokenCard({ label, value }: { label: string; value: string }) { + return ( +
+
{label}
+
{value}
+
+ ); +} diff --git a/apps/web/features/runtimes/components/usage-section.tsx b/apps/web/features/runtimes/components/usage-section.tsx new file mode 100644 index 00000000..b8a26c34 --- /dev/null +++ b/apps/web/features/runtimes/components/usage-section.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { BarChart3 } from "lucide-react"; +import type { RuntimeUsage } from "@/shared/types"; +import { api } from "@/shared/api"; +import { formatTokens, estimateCost, aggregateByDate } from "../utils"; +import { TokenCard } from "./shared"; +import { + ActivityHeatmap, + HourlyActivityChart, + DailyTokenChart, + DailyCostChart, + ModelDistributionChart, +} from "./charts"; + +const TIME_RANGES = [ + { label: "7d", days: 7 }, + { label: "30d", days: 30 }, + { label: "90d", days: 90 }, +] as const; + +type TimeRange = (typeof TIME_RANGES)[number]["days"]; + +export function UsageSection({ runtimeId }: { runtimeId: string }) { + const [usage, setUsage] = useState([]); + const [loading, setLoading] = useState(true); + const [days, setDays] = useState(30); + + useEffect(() => { + setLoading(true); + api + .getRuntimeUsage(runtimeId, { days: 90 }) // always fetch 90d, filter client-side + .then(setUsage) + .catch(() => setUsage([])) + .finally(() => setLoading(false)); + }, [runtimeId]); + + if (loading) { + return ( +
Loading usage...
+ ); + } + + if (usage.length === 0) { + return ( +
+ +

No usage data yet

+
+ ); + } + + // Filter by selected time range + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - days); + const cutoff = cutoffDate.toISOString().slice(0, 10); + const filtered = usage.filter((u) => u.date >= cutoff); + + // Compute totals + const totals = filtered.reduce( + (acc, u) => ({ + input: acc.input + u.input_tokens, + output: acc.output + u.output_tokens, + cacheRead: acc.cacheRead + u.cache_read_tokens, + cacheWrite: acc.cacheWrite + u.cache_write_tokens, + cost: acc.cost + estimateCost(u), + }), + { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }, + ); + + const { dailyTokens, dailyCost, modelDist } = aggregateByDate(filtered); + + // Group by date for the table + const byDate = new Map(); + for (const u of filtered) { + const existing = byDate.get(u.date) ?? []; + existing.push(u); + byDate.set(u.date, existing); + } + + return ( +
+ {/* Time range selector */} +
+ {TIME_RANGES.map((range) => ( + + ))} +
+ + {/* Summary cards */} +
+ + + + +
+ + {totals.cost > 0 && ( +
+ + Estimated cost ({days}d):{" "} + + + ${totals.cost.toFixed(2)} + +
+ )} + + {/* Heatmap + Hourly */} +
+ + +
+ + {/* Token & Cost charts */} +
+ + +
+ + + + {/* Daily breakdown table */} +
+
+
Date
+
Model
+
Input
+
Output
+
Cache R
+
Cache W
+
+
+ {[...byDate.entries()].map(([date, rows]) => + rows.map((row, i) => ( +
+
{date}
+
{row.model}
+
+ {formatTokens(row.input_tokens)} +
+
+ {formatTokens(row.output_tokens)} +
+
+ {formatTokens(row.cache_read_tokens)} +
+
+ {formatTokens(row.cache_write_tokens)} +
+
+ )), + )} +
+
+
+ ); +} diff --git a/apps/web/features/runtimes/index.ts b/apps/web/features/runtimes/index.ts index 5fa5b0bf..c24959ba 100644 --- a/apps/web/features/runtimes/index.ts +++ b/apps/web/features/runtimes/index.ts @@ -1 +1,2 @@ export { RuntimesPage } from "./components"; +export { useRuntimeStore } from "./store"; diff --git a/apps/web/features/runtimes/store.ts b/apps/web/features/runtimes/store.ts new file mode 100644 index 00000000..6256b30c --- /dev/null +++ b/apps/web/features/runtimes/store.ts @@ -0,0 +1,84 @@ +"use client"; + +import { create } from "zustand"; +import type { AgentRuntime } from "@/shared/types"; +import { api } from "@/shared/api"; +import { useWorkspaceStore } from "@/features/workspace"; + +interface RuntimeState { + runtimes: AgentRuntime[]; + selectedId: string; + fetching: boolean; +} + +interface RuntimeActions { + fetchRuntimes: () => Promise; + setSelectedId: (id: string) => void; + /** Patch a single runtime in-place (e.g. status/last_seen_at from WS event). */ + patchRuntime: (id: string, updates: Partial) => void; + /** Replace the full runtimes list (used on daemon:register events). */ + setRuntimes: (runtimes: AgentRuntime[]) => void; + /** Delete a runtime by ID (calls API). */ + deleteRuntime: (id: string) => Promise; +} + +type RuntimeStore = RuntimeState & RuntimeActions; + +export const useRuntimeStore = create((set, get) => ({ + // State + runtimes: [], + selectedId: "", + fetching: true, + + // Actions + fetchRuntimes: async () => { + const workspace = useWorkspaceStore.getState().workspace; + if (!workspace) return; + try { + const data = await api.listRuntimes({ workspace_id: workspace.id }); + const { selectedId } = get(); + set({ + runtimes: data, + fetching: false, + // Auto-select first if nothing selected + selectedId: selectedId && data.some((r) => r.id === selectedId) + ? selectedId + : data[0]?.id ?? "", + }); + } catch { + set({ fetching: false }); + } + }, + + setSelectedId: (id) => set({ selectedId: id }), + + patchRuntime: (id, updates) => { + set((state) => ({ + runtimes: state.runtimes.map((r) => + r.id === id ? { ...r, ...updates } : r, + ), + })); + }, + + setRuntimes: (runtimes) => { + const { selectedId } = get(); + set({ + runtimes, + selectedId: selectedId && runtimes.some((r) => r.id === selectedId) + ? selectedId + : runtimes[0]?.id ?? "", + }); + }, + + deleteRuntime: async (id) => { + await api.deleteRuntime(id); + const remaining = get().runtimes.filter((r) => r.id !== id); + const { selectedId } = get(); + set({ + runtimes: remaining, + selectedId: selectedId === id + ? remaining[0]?.id ?? "" + : selectedId, + }); + }, +})); diff --git a/apps/web/features/runtimes/utils.ts b/apps/web/features/runtimes/utils.ts new file mode 100644 index 00000000..10aa3b17 --- /dev/null +++ b/apps/web/features/runtimes/utils.ts @@ -0,0 +1,141 @@ +import type { RuntimeUsage } from "@/shared/types"; + +// --------------------------------------------------------------------------- +// Formatting helpers +// --------------------------------------------------------------------------- + +export function formatLastSeen(lastSeenAt: string | null): string { + if (!lastSeenAt) return "Never"; + const diff = Date.now() - new Date(lastSeenAt).getTime(); + if (diff < 60_000) return "Just now"; + if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`; + if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`; + return `${Math.floor(diff / 86_400_000)}d ago`; +} + +export function formatTokens(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toLocaleString(); +} + +// --------------------------------------------------------------------------- +// Cost estimation +// --------------------------------------------------------------------------- + +// Pricing per million tokens (USD) +const MODEL_PRICING: Record< + string, + { input: number; output: number; cacheRead: number; cacheWrite: number } +> = { + "claude-haiku-4-5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 }, + "claude-sonnet-4-5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "claude-sonnet-4-6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 }, + "claude-opus-4-5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, + "claude-opus-4-6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 }, +}; + +export function estimateCost(usage: RuntimeUsage): number { + const model = usage.model; + let pricing = MODEL_PRICING[model]; + if (!pricing) { + for (const [key, p] of Object.entries(MODEL_PRICING)) { + if (model.startsWith(key)) { + pricing = p; + break; + } + } + } + if (!pricing) return 0; + + return ( + (usage.input_tokens * pricing.input + + usage.output_tokens * pricing.output + + usage.cache_read_tokens * pricing.cacheRead + + usage.cache_write_tokens * pricing.cacheWrite) / + 1_000_000 + ); +} + +// --------------------------------------------------------------------------- +// Data aggregation +// --------------------------------------------------------------------------- + +export interface DailyTokenData { + date: string; + label: string; + input: number; + output: number; + cacheRead: number; + cacheWrite: number; +} + +export interface DailyCostData { + date: string; + label: string; + cost: number; +} + +export interface ModelDistribution { + model: string; + tokens: number; + cost: number; +} + +export function aggregateByDate(usage: RuntimeUsage[]): { + dailyTokens: DailyTokenData[]; + dailyCost: DailyCostData[]; + modelDist: ModelDistribution[]; +} { + 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 }; +} diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index dbde3195..8f7a07f6 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -283,6 +283,10 @@ export class ApiClient { return this.fetch(`/api/runtimes/${runtimeId}/activity`); } + async deleteRuntime(runtimeId: string): Promise { + await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" }); + } + async pingRuntime(runtimeId: string): Promise { return this.fetch(`/api/runtimes/${runtimeId}/ping`, { method: "POST" }); } diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index dc488e83..956b5535 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -162,6 +162,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.Delete("/{runtimeId}", h.DeleteAgentRuntime) r.Get("/{runtimeId}/usage", h.GetRuntimeUsage) r.Get("/{runtimeId}/activity", h.GetRuntimeTaskActivity) r.Post("/{runtimeId}/ping", h.InitiatePing) diff --git a/server/internal/handler/runtime.go b/server/internal/handler/runtime.go index bccf4051..40535e45 100644 --- a/server/internal/handler/runtime.go +++ b/server/internal/handler/runtime.go @@ -192,6 +192,26 @@ func (h *Handler) GetRuntimeTaskActivity(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusOK, resp) } +func (h *Handler) DeleteAgentRuntime(w http.ResponseWriter, r *http.Request) { + runtimeID := chi.URLParam(r, "runtimeId") + workspaceID := resolveWorkspaceID(r) + + if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "runtime not found"); !ok { + return + } + + err := h.Queries.DeleteAgentRuntime(r.Context(), db.DeleteAgentRuntimeParams{ + ID: parseUUID(runtimeID), + WorkspaceID: parseUUID(workspaceID), + }) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to delete runtime") + return + } + + w.WriteHeader(http.StatusNoContent) +} + 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.sql.go b/server/pkg/db/generated/runtime.sql.go index d871d72d..d213607f 100644 --- a/server/pkg/db/generated/runtime.sql.go +++ b/server/pkg/db/generated/runtime.sql.go @@ -11,6 +11,21 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const deleteAgentRuntime = `-- name: DeleteAgentRuntime :exec +DELETE FROM agent_runtime +WHERE id = $1 AND workspace_id = $2 +` + +type DeleteAgentRuntimeParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +func (q *Queries) DeleteAgentRuntime(ctx context.Context, arg DeleteAgentRuntimeParams) error { + _, err := q.db.Exec(ctx, deleteAgentRuntime, arg.ID, arg.WorkspaceID) + return err +} + const getAgentRuntime = `-- name: GetAgentRuntime :one SELECT id, workspace_id, daemon_id, name, runtime_mode, provider, status, device_info, metadata, last_seen_at, created_at, updated_at FROM agent_runtime WHERE id = $1 diff --git a/server/pkg/db/queries/runtime.sql b/server/pkg/db/queries/runtime.sql index 6aabb657..65e06f09 100644 --- a/server/pkg/db/queries/runtime.sql +++ b/server/pkg/db/queries/runtime.sql @@ -51,3 +51,7 @@ SET status = 'offline', updated_at = now() WHERE status = 'online' AND last_seen_at < now() - make_interval(secs => @stale_seconds::double precision) RETURNING id, workspace_id; + +-- name: DeleteAgentRuntime :exec +DELETE FROM agent_runtime +WHERE id = $1 AND workspace_id = $2;