refactor(runtimes): split monolithic page, add zustand store, time range selector, and delete support
- Split 1189-line runtimes-page.tsx into focused sub-components (list, detail, ping, usage, 5 chart files, shared UI, utils) - Add useRuntimeStore zustand store for shared runtime state across pages (agents page now uses it too) - Add 7d/30d/90d time range selector to usage charts - Add full-stack runtime delete: SQL query, Go handler, API route, frontend with confirmation dialog - Remove unused daemon:heartbeat WS listener (server never broadcasts it)
This commit is contained in:
parent
42f72371bd
commit
ceba8556f5
21 changed files with 1398 additions and 1138 deletions
|
|
@ -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<string>("");
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [runtimes, setRuntimes] = useState<RuntimeDevice[]>([]);
|
||||
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(() => {
|
||||
|
|
|
|||
|
|
@ -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<string, number>();
|
||||
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 (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-3">Activity</h4>
|
||||
<div className="overflow-x-auto">
|
||||
<svg width={svgWidth} height={svgHeight} className="block">
|
||||
{monthLabels.map((m) => (
|
||||
<text
|
||||
key={`${m.label}-${m.week}`}
|
||||
x={labelWidth + m.week * (CELL_SIZE + CELL_GAP)}
|
||||
y={10}
|
||||
className="fill-muted-foreground"
|
||||
fontSize={9}
|
||||
>
|
||||
{m.label}
|
||||
</text>
|
||||
))}
|
||||
{DAY_LABELS.map((label, i) =>
|
||||
label ? (
|
||||
<text
|
||||
key={i}
|
||||
x={0}
|
||||
y={14 + i * (CELL_SIZE + CELL_GAP) + CELL_SIZE - 1}
|
||||
className="fill-muted-foreground"
|
||||
fontSize={9}
|
||||
>
|
||||
{label}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
{cells.map((c) => (
|
||||
<rect
|
||||
key={c.date}
|
||||
x={labelWidth + c.week * (CELL_SIZE + CELL_GAP)}
|
||||
y={14 + c.dayOfWeek * (CELL_SIZE + CELL_GAP)}
|
||||
width={CELL_SIZE}
|
||||
height={CELL_SIZE}
|
||||
rx={2}
|
||||
fill={getHeatmapColor(c.level)}
|
||||
className="transition-colors"
|
||||
>
|
||||
<title>
|
||||
{c.date}:{" "}
|
||||
{c.tokens > 0
|
||||
? formatTokens(c.tokens) + " tokens"
|
||||
: "No activity"}
|
||||
</title>
|
||||
</rect>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="mt-2 flex items-center justify-end gap-1 text-[10px] text-muted-foreground">
|
||||
<span>Less</span>
|
||||
{[0, 1, 2, 3, 4].map((level) => (
|
||||
<div
|
||||
key={level}
|
||||
className="h-[10px] w-[10px] rounded-[2px]"
|
||||
style={{ backgroundColor: getHeatmapColor(level) }}
|
||||
/>
|
||||
))}
|
||||
<span>More</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-3">Daily Estimated Cost</h4>
|
||||
<ChartContainer config={costChartConfig} className="aspect-[2.5/1] w-full">
|
||||
<BarChart data={data} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(v: number) => `$${v}`}
|
||||
width={50}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) =>
|
||||
typeof value === "number" ? `$${value.toFixed(2)}` : String(value)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Bar dataKey="cost" fill="var(--color-cost)" radius={[3, 3, 0, 0]} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-3">Daily Token Usage</h4>
|
||||
<ChartContainer config={tokenChartConfig} className="aspect-[2.5/1] w-full">
|
||||
<AreaChart data={data} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
tickFormatter={(v: number) => formatTokens(v)}
|
||||
width={50}
|
||||
/>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) =>
|
||||
typeof value === "number" ? formatTokens(value) : String(value)
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ChartLegend content={<ChartLegendContent />} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="input"
|
||||
stackId="1"
|
||||
stroke="var(--color-input)"
|
||||
fill="var(--color-input)"
|
||||
fillOpacity={0.4}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="output"
|
||||
stackId="1"
|
||||
stroke="var(--color-output)"
|
||||
fill="var(--color-output)"
|
||||
fillOpacity={0.4}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cacheRead"
|
||||
stackId="1"
|
||||
stroke="var(--color-cacheRead)"
|
||||
fill="var(--color-cacheRead)"
|
||||
fillOpacity={0.4}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="cacheWrite"
|
||||
stackId="1"
|
||||
stroke="var(--color-cacheWrite)"
|
||||
fill="var(--color-cacheWrite)"
|
||||
fillOpacity={0.4}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ChartContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<RuntimeHourlyActivity[]>([]);
|
||||
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 (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-3">Hourly Distribution</h4>
|
||||
{loading ? (
|
||||
<div className="flex h-[140px] items-center justify-center text-xs text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
) : !hasData ? (
|
||||
<div className="flex h-[140px] flex-col items-center justify-center">
|
||||
<BarChart3 className="h-5 w-5 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">No task data yet</p>
|
||||
</div>
|
||||
) : (
|
||||
<ChartContainer config={hourlyChartConfig} className="aspect-[2.5/1] w-full">
|
||||
<BarChart data={chartData} margin={{ left: 0, right: 0, top: 4, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
interval={2}
|
||||
fontSize={10}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
width={30}
|
||||
allowDecimals={false}
|
||||
/>
|
||||
<ChartTooltip content={<ChartTooltipContent />} />
|
||||
<Bar dataKey="count" fill="var(--color-count)" radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ChartContainer>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
apps/web/features/runtimes/components/charts/index.ts
Normal file
5
apps/web/features/runtimes/components/charts/index.ts
Normal file
|
|
@ -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";
|
||||
|
|
@ -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 (
|
||||
<div className="rounded-lg border p-4">
|
||||
<h4 className="text-xs font-medium text-muted-foreground mb-3">Token Usage by Model</h4>
|
||||
<ChartContainer config={chartConfig} className="mx-auto aspect-square max-h-[200px]">
|
||||
<PieChart>
|
||||
<ChartTooltip
|
||||
content={
|
||||
<ChartTooltipContent
|
||||
formatter={(value) =>
|
||||
typeof value === "number" ? formatTokens(value) : String(value)
|
||||
}
|
||||
nameKey="model"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="tokens"
|
||||
nameKey="model"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
paddingAngle={2}
|
||||
>
|
||||
{data.map((entry, i) => (
|
||||
<Cell
|
||||
key={entry.model}
|
||||
fill={MODEL_COLORS[i % MODEL_COLORS.length]}
|
||||
/>
|
||||
))}
|
||||
<Label
|
||||
content={({ viewBox }) => {
|
||||
if (viewBox && "cx" in viewBox && "cy" in viewBox) {
|
||||
return (
|
||||
<text x={viewBox.cx} y={viewBox.cy} textAnchor="middle" dominantBaseline="middle">
|
||||
<tspan x={viewBox.cx} y={viewBox.cy} className="fill-foreground text-lg font-bold">
|
||||
{formatTokens(totalTokens)}
|
||||
</tspan>
|
||||
<tspan x={viewBox.cx} y={(viewBox.cy ?? 0) + 18} className="fill-muted-foreground text-xs">
|
||||
tokens
|
||||
</tspan>
|
||||
</text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
/>
|
||||
</Pie>
|
||||
</PieChart>
|
||||
</ChartContainer>
|
||||
{/* Model legend with cost */}
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{data.map((d, i) => (
|
||||
<div key={d.model} className="flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-full"
|
||||
style={{ backgroundColor: MODEL_COLORS[i % MODEL_COLORS.length] }}
|
||||
/>
|
||||
<span className="truncate font-mono">{d.model}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0 text-muted-foreground tabular-nums">
|
||||
<span>{formatTokens(d.tokens)}</span>
|
||||
{d.cost > 0 && <span>${d.cost.toFixed(2)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
apps/web/features/runtimes/components/ping-section.tsx
Normal file
120
apps/web/features/runtimes/components/ping-section.tsx
Normal file
|
|
@ -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<RuntimePingStatus | null>(null);
|
||||
const [output, setOutput] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [durationMs, setDurationMs] = useState<number | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | 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 (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
onClick={handleTest}
|
||||
disabled={testing}
|
||||
>
|
||||
{testing ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Zap className="h-3 w-3" />
|
||||
)}
|
||||
{testing ? "Testing..." : "Test Connection"}
|
||||
</Button>
|
||||
|
||||
{config && Icon && (
|
||||
<span className={`inline-flex items-center gap-1 text-xs ${config.color}`}>
|
||||
<Icon className={`h-3 w-3 ${isActive ? "animate-spin" : ""}`} />
|
||||
{config.label}
|
||||
{durationMs != null && (
|
||||
<span className="text-muted-foreground">
|
||||
({(durationMs / 1000).toFixed(1)}s)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{status === "completed" && output && (
|
||||
<div className="rounded-lg border bg-success/5 px-3 py-2">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap">{output}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(status === "failed" || status === "timeout") && error && (
|
||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2">
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
158
apps/web/features/runtimes/components/runtime-detail.tsx
Normal file
158
apps/web/features/runtimes/components/runtime-detail.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex h-12 shrink-0 items-center justify-between border-b px-4">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md ${
|
||||
runtime.status === "online" ? "bg-success/10" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<RuntimeModeIcon mode={runtime.runtime_mode} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold truncate">{runtime.name}</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusBadge status={runtime.status} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setShowDelete(true)}
|
||||
className="text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* Info grid */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<InfoField label="Runtime Mode" value={runtime.runtime_mode} />
|
||||
<InfoField label="Provider" value={runtime.provider} />
|
||||
<InfoField label="Status" value={runtime.status} />
|
||||
<InfoField
|
||||
label="Last Seen"
|
||||
value={formatLastSeen(runtime.last_seen_at)}
|
||||
/>
|
||||
{runtime.device_info && (
|
||||
<InfoField label="Device" value={runtime.device_info} />
|
||||
)}
|
||||
{runtime.daemon_id && (
|
||||
<InfoField label="Daemon ID" value={runtime.daemon_id} mono />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Connection Test */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-3">
|
||||
Connection Test
|
||||
</h3>
|
||||
<PingSection runtimeId={runtime.id} />
|
||||
</div>
|
||||
|
||||
{/* Usage */}
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-3">
|
||||
Token Usage
|
||||
</h3>
|
||||
<UsageSection runtimeId={runtime.id} />
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
{runtime.metadata && Object.keys(runtime.metadata).length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-muted-foreground mb-2">
|
||||
Metadata
|
||||
</h3>
|
||||
<div className="rounded-lg border bg-muted/30 p-3">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(runtime.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Timestamps */}
|
||||
<div className="grid grid-cols-2 gap-4 border-t pt-4">
|
||||
<InfoField
|
||||
label="Created"
|
||||
value={new Date(runtime.created_at).toLocaleString()}
|
||||
/>
|
||||
<InfoField
|
||||
label="Updated"
|
||||
value={new Date(runtime.updated_at).toLocaleString()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation dialog */}
|
||||
<Dialog open={showDelete} onOpenChange={setShowDelete}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Runtime</DialogTitle>
|
||||
<DialogDescription>
|
||||
Are you sure you want to delete "{runtime.name}"? This
|
||||
will remove the runtime and its usage data. This action cannot be
|
||||
undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowDelete(false)}
|
||||
disabled={deleting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
>
|
||||
{deleting ? "Deleting..." : "Delete"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
89
apps/web/features/runtimes/components/runtime-list.tsx
Normal file
89
apps/web/features/runtimes/components/runtime-list.tsx
Normal file
|
|
@ -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 (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`flex w-full items-center gap-3 px-4 py-3 text-left transition-colors ${
|
||||
isSelected ? "bg-accent" : "hover:bg-accent/50"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-lg ${
|
||||
runtime.status === "online" ? "bg-success/10" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<RuntimeModeIcon mode={runtime.runtime_mode} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{runtime.name}</div>
|
||||
<div className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{runtime.provider} · {runtime.runtime_mode}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`h-2 w-2 shrink-0 rounded-full ${
|
||||
runtime.status === "online" ? "bg-success" : "bg-muted-foreground/40"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function RuntimeList({
|
||||
runtimes,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: {
|
||||
runtimes: AgentRuntime[];
|
||||
selectedId: string;
|
||||
onSelect: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="overflow-y-auto h-full border-r">
|
||||
<div className="flex h-12 items-center justify-between border-b px-4">
|
||||
<h1 className="text-sm font-semibold">Runtimes</h1>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{runtimes.filter((r) => r.status === "online").length}/
|
||||
{runtimes.length} online
|
||||
</span>
|
||||
</div>
|
||||
{runtimes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-12">
|
||||
<Server className="h-8 w-8 text-muted-foreground/40" />
|
||||
<p className="mt-3 text-sm text-muted-foreground">
|
||||
No runtimes registered
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground text-center">
|
||||
Run{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">
|
||||
multica daemon start
|
||||
</code>{" "}
|
||||
to register a local runtime.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{runtimes.map((runtime) => (
|
||||
<RuntimeListItem
|
||||
key={runtime.id}
|
||||
runtime={runtime}
|
||||
isSelected={runtime.id === selectedId}
|
||||
onClick={() => onSelect(runtime.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
57
apps/web/features/runtimes/components/shared.tsx
Normal file
57
apps/web/features/runtimes/components/shared.tsx
Normal file
|
|
@ -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" ? (
|
||||
<Cloud className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<Monitor className="h-3.5 w-3.5" />
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBadge({ status }: { status: string }) {
|
||||
const isOnline = status === "online";
|
||||
return (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={isOnline ? "bg-success/10 text-success" : ""}
|
||||
>
|
||||
{isOnline ? (
|
||||
<Wifi className="h-3 w-3" />
|
||||
) : (
|
||||
<WifiOff className="h-3 w-3" />
|
||||
)}
|
||||
{isOnline ? "Online" : "Offline"}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export function InfoField({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div
|
||||
className={`mt-0.5 text-sm truncate ${mono ? "font-mono text-xs" : ""}`}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TokenCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-0.5 text-sm font-semibold tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
apps/web/features/runtimes/components/usage-section.tsx
Normal file
172
apps/web/features/runtimes/components/usage-section.tsx
Normal file
|
|
@ -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<RuntimeUsage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [days, setDays] = useState<TimeRange>(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 (
|
||||
<div className="text-xs text-muted-foreground">Loading usage...</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (usage.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center rounded-lg border border-dashed py-6">
|
||||
<BarChart3 className="h-5 w-5 text-muted-foreground/40" />
|
||||
<p className="mt-2 text-xs text-muted-foreground">No usage data yet</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<string, RuntimeUsage[]>();
|
||||
for (const u of filtered) {
|
||||
const existing = byDate.get(u.date) ?? [];
|
||||
existing.push(u);
|
||||
byDate.set(u.date, existing);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Time range selector */}
|
||||
<div className="flex items-center gap-1">
|
||||
{TIME_RANGES.map((range) => (
|
||||
<button
|
||||
key={range.days}
|
||||
onClick={() => setDays(range.days)}
|
||||
className={`rounded-md px-2.5 py-1 text-xs font-medium transition-colors ${
|
||||
days === range.days
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground"
|
||||
}`}
|
||||
>
|
||||
{range.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<TokenCard label="Input" value={formatTokens(totals.input)} />
|
||||
<TokenCard label="Output" value={formatTokens(totals.output)} />
|
||||
<TokenCard label="Cache Read" value={formatTokens(totals.cacheRead)} />
|
||||
<TokenCard label="Cache Write" value={formatTokens(totals.cacheWrite)} />
|
||||
</div>
|
||||
|
||||
{totals.cost > 0 && (
|
||||
<div className="rounded-lg border bg-muted/30 px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Estimated cost ({days}d):{" "}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">
|
||||
${totals.cost.toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Heatmap + Hourly */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<ActivityHeatmap usage={usage} />
|
||||
<HourlyActivityChart runtimeId={runtimeId} />
|
||||
</div>
|
||||
|
||||
{/* Token & Cost charts */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<DailyTokenChart data={dailyTokens} />
|
||||
<DailyCostChart data={dailyCost} />
|
||||
</div>
|
||||
|
||||
<ModelDistributionChart data={modelDist} />
|
||||
|
||||
{/* Daily breakdown table */}
|
||||
<div className="rounded-lg border">
|
||||
<div className="grid grid-cols-[100px_1fr_80px_80px_80px_80px] gap-2 border-b px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||
<div>Date</div>
|
||||
<div>Model</div>
|
||||
<div className="text-right">Input</div>
|
||||
<div className="text-right">Output</div>
|
||||
<div className="text-right">Cache R</div>
|
||||
<div className="text-right">Cache W</div>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto divide-y">
|
||||
{[...byDate.entries()].map(([date, rows]) =>
|
||||
rows.map((row, i) => (
|
||||
<div
|
||||
key={`${date}-${row.model}-${i}`}
|
||||
className="grid grid-cols-[100px_1fr_80px_80px_80px_80px] gap-2 px-3 py-1.5 text-xs"
|
||||
>
|
||||
<div className="text-muted-foreground">{date}</div>
|
||||
<div className="truncate font-mono">{row.model}</div>
|
||||
<div className="text-right tabular-nums">
|
||||
{formatTokens(row.input_tokens)}
|
||||
</div>
|
||||
<div className="text-right tabular-nums">
|
||||
{formatTokens(row.output_tokens)}
|
||||
</div>
|
||||
<div className="text-right tabular-nums">
|
||||
{formatTokens(row.cache_read_tokens)}
|
||||
</div>
|
||||
<div className="text-right tabular-nums">
|
||||
{formatTokens(row.cache_write_tokens)}
|
||||
</div>
|
||||
</div>
|
||||
)),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export { RuntimesPage } from "./components";
|
||||
export { useRuntimeStore } from "./store";
|
||||
|
|
|
|||
84
apps/web/features/runtimes/store.ts
Normal file
84
apps/web/features/runtimes/store.ts
Normal file
|
|
@ -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<void>;
|
||||
setSelectedId: (id: string) => void;
|
||||
/** Patch a single runtime in-place (e.g. status/last_seen_at from WS event). */
|
||||
patchRuntime: (id: string, updates: Partial<AgentRuntime>) => 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<void>;
|
||||
}
|
||||
|
||||
type RuntimeStore = RuntimeState & RuntimeActions;
|
||||
|
||||
export const useRuntimeStore = create<RuntimeStore>((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,
|
||||
});
|
||||
},
|
||||
}));
|
||||
141
apps/web/features/runtimes/utils.ts
Normal file
141
apps/web/features/runtimes/utils.ts
Normal file
|
|
@ -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<string, Omit<DailyTokenData, "label">>();
|
||||
const costMap = new Map<string, number>();
|
||||
const modelMap = new Map<string, { tokens: number; cost: number }>();
|
||||
|
||||
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 };
|
||||
}
|
||||
|
|
@ -283,6 +283,10 @@ export class ApiClient {
|
|||
return this.fetch(`/api/runtimes/${runtimeId}/activity`);
|
||||
}
|
||||
|
||||
async deleteRuntime(runtimeId: string): Promise<void> {
|
||||
await this.fetch(`/api/runtimes/${runtimeId}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async pingRuntime(runtimeId: string): Promise<RuntimePing> {
|
||||
return this.fetch(`/api/runtimes/${runtimeId}/ping`, { method: "POST" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue