@agent-native/dispatch 0.1.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/index.d.ts.map +1 -1
- package/dist/actions/index.js +2 -0
- package/dist/actions/index.js.map +1 -1
- package/dist/actions/list-dispatch-usage-metrics.d.ts +3 -0
- package/dist/actions/list-dispatch-usage-metrics.d.ts.map +1 -0
- package/dist/actions/list-dispatch-usage-metrics.js +18 -0
- package/dist/actions/list-dispatch-usage-metrics.js.map +1 -0
- package/dist/actions/navigate.d.ts +1 -0
- package/dist/actions/navigate.d.ts.map +1 -1
- package/dist/actions/navigate.js +3 -17
- package/dist/actions/navigate.js.map +1 -1
- package/dist/actions/view-screen.d.ts.map +1 -1
- package/dist/actions/view-screen.js +19 -0
- package/dist/actions/view-screen.js.map +1 -1
- package/dist/components/agents-panel.js +3 -3
- package/dist/components/app-keys-popover.js +2 -2
- package/dist/components/create-app-popover.js +2 -2
- package/dist/components/dispatch-shell.js +2 -2
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js.map +1 -1
- package/dist/components/layout/Header.js +4 -4
- package/dist/components/layout/Header.js.map +1 -1
- package/dist/components/layout/Layout.d.ts +28 -3
- package/dist/components/layout/Layout.d.ts.map +1 -1
- package/dist/components/layout/Layout.js +137 -26
- package/dist/components/layout/Layout.js.map +1 -1
- package/dist/components/messaging-setup-panel.js +4 -4
- package/dist/components/ui/accordion.js +1 -1
- package/dist/components/ui/alert-dialog.js +2 -2
- package/dist/components/ui/alert.js +1 -1
- package/dist/components/ui/avatar.js +1 -1
- package/dist/components/ui/badge.js +1 -1
- package/dist/components/ui/breadcrumb.js +1 -1
- package/dist/components/ui/button.js +1 -1
- package/dist/components/ui/calendar.js +2 -2
- package/dist/components/ui/card.js +1 -1
- package/dist/components/ui/carousel.d.ts +2 -2
- package/dist/components/ui/carousel.js +2 -2
- package/dist/components/ui/chart.js +1 -1
- package/dist/components/ui/checkbox.js +1 -1
- package/dist/components/ui/command.js +2 -2
- package/dist/components/ui/context-menu.js +1 -1
- package/dist/components/ui/dialog.js +1 -1
- package/dist/components/ui/drawer.js +1 -1
- package/dist/components/ui/dropdown-menu.js +1 -1
- package/dist/components/ui/form.js +2 -2
- package/dist/components/ui/hover-card.js +1 -1
- package/dist/components/ui/input-otp.js +1 -1
- package/dist/components/ui/input.js +1 -1
- package/dist/components/ui/label.js +1 -1
- package/dist/components/ui/menubar.js +1 -1
- package/dist/components/ui/navigation-menu.js +1 -1
- package/dist/components/ui/pagination.d.ts +1 -1
- package/dist/components/ui/pagination.js +2 -2
- package/dist/components/ui/popover.js +1 -1
- package/dist/components/ui/progress.js +1 -1
- package/dist/components/ui/radio-group.js +1 -1
- package/dist/components/ui/resizable.js +1 -1
- package/dist/components/ui/scroll-area.js +1 -1
- package/dist/components/ui/select.js +1 -1
- package/dist/components/ui/separator.js +1 -1
- package/dist/components/ui/sheet.js +1 -1
- package/dist/components/ui/sidebar.d.ts +2 -2
- package/dist/components/ui/sidebar.js +8 -8
- package/dist/components/ui/skeleton.js +1 -1
- package/dist/components/ui/slider.js +1 -1
- package/dist/components/ui/sonner.js +1 -1
- package/dist/components/ui/spinner.js +1 -1
- package/dist/components/ui/switch.js +1 -1
- package/dist/components/ui/table.js +1 -1
- package/dist/components/ui/tabs.js +1 -1
- package/dist/components/ui/textarea.js +1 -1
- package/dist/components/ui/toast.js +1 -1
- package/dist/components/ui/toaster.js +2 -2
- package/dist/components/ui/toggle-group.js +2 -2
- package/dist/components/ui/toggle.js +1 -1
- package/dist/components/ui/tooltip.js +1 -1
- package/dist/components/ui/use-toast.d.ts +1 -1
- package/dist/components/ui/use-toast.js +1 -1
- package/dist/hooks/use-navigation-state.d.ts +2 -1
- package/dist/hooks/use-navigation-state.d.ts.map +1 -1
- package/dist/hooks/use-navigation-state.js +36 -8
- package/dist/hooks/use-navigation-state.js.map +1 -1
- package/dist/hooks/use-toast.d.ts +1 -1
- package/dist/routes/index.d.ts.map +1 -1
- package/dist/routes/index.js +3 -2
- package/dist/routes/index.js.map +1 -1
- package/dist/routes/pages/_index.js +1 -1
- package/dist/routes/pages/agents.js +2 -2
- package/dist/routes/pages/approval.js +2 -2
- package/dist/routes/pages/approvals.js +4 -4
- package/dist/routes/pages/apps.$appId.js +3 -3
- package/dist/routes/pages/apps.js +5 -5
- package/dist/routes/pages/audit.js +1 -1
- package/dist/routes/pages/destinations.js +6 -6
- package/dist/routes/pages/extensions.$id.d.ts +2 -0
- package/dist/routes/pages/extensions.$id.d.ts.map +1 -0
- package/dist/routes/pages/extensions.$id.js +6 -0
- package/dist/routes/pages/extensions.$id.js.map +1 -0
- package/dist/routes/pages/extensions._index.d.ts +2 -0
- package/dist/routes/pages/extensions._index.d.ts.map +1 -0
- package/dist/routes/pages/extensions._index.js +6 -0
- package/dist/routes/pages/extensions._index.js.map +1 -0
- package/dist/routes/pages/identities.js +2 -2
- package/dist/routes/pages/integrations.js +4 -4
- package/dist/routes/pages/messaging.js +2 -2
- package/dist/routes/pages/metrics.d.ts +5 -0
- package/dist/routes/pages/metrics.d.ts.map +1 -0
- package/dist/routes/pages/metrics.js +135 -0
- package/dist/routes/pages/metrics.js.map +1 -0
- package/dist/routes/pages/new-app.js +1 -1
- package/dist/routes/pages/overview.d.ts.map +1 -1
- package/dist/routes/pages/overview.js +9 -17
- package/dist/routes/pages/overview.js.map +1 -1
- package/dist/routes/pages/team.js +1 -1
- package/dist/routes/pages/vault.js +10 -10
- package/dist/routes/pages/workspace.js +10 -10
- package/dist/server/lib/pre-auth-routing.d.ts.map +1 -1
- package/dist/server/lib/pre-auth-routing.js +9 -2
- package/dist/server/lib/pre-auth-routing.js.map +1 -1
- package/dist/server/lib/usage-metrics-store.d.ts +93 -0
- package/dist/server/lib/usage-metrics-store.d.ts.map +1 -0
- package/dist/server/lib/usage-metrics-store.js +386 -0
- package/dist/server/lib/usage-metrics-store.js.map +1 -0
- package/package.json +9 -5
- package/src/actions/index.ts +2 -0
- package/src/actions/list-dispatch-usage-metrics.ts +19 -0
- package/src/actions/navigate.ts +5 -17
- package/src/actions/view-screen.ts +18 -0
- package/src/components/index.ts +6 -0
- package/src/components/layout/Header.tsx +1 -1
- package/src/components/layout/Layout.tsx +194 -37
- package/src/hooks/use-navigation-state.ts +57 -8
- package/src/routes/index.ts +3 -2
- package/src/routes/pages/extensions.$id.tsx +5 -0
- package/src/routes/pages/extensions._index.tsx +5 -0
- package/src/routes/pages/metrics.tsx +667 -0
- package/src/routes/pages/overview.tsx +0 -10
- package/src/server/lib/pre-auth-routing.ts +10 -2
- package/src/server/lib/usage-metrics-store.ts +605 -0
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
import { useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
import { useActionQuery } from "@agent-native/core/client";
|
|
3
|
+
import {
|
|
4
|
+
IconActivity,
|
|
5
|
+
IconAlertTriangle,
|
|
6
|
+
IconApps,
|
|
7
|
+
IconChartBar,
|
|
8
|
+
IconClockHour4,
|
|
9
|
+
IconCoin,
|
|
10
|
+
IconMessages,
|
|
11
|
+
IconUsersGroup,
|
|
12
|
+
} from "@tabler/icons-react";
|
|
13
|
+
import { DispatchShell } from "@/components/dispatch-shell";
|
|
14
|
+
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
15
|
+
import { Badge } from "@/components/ui/badge";
|
|
16
|
+
import { Button } from "@/components/ui/button";
|
|
17
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
18
|
+
import { cn } from "@/lib/utils";
|
|
19
|
+
|
|
20
|
+
export function meta() {
|
|
21
|
+
return [{ title: "Metrics — Dispatch" }];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface UsageMetricBucket {
|
|
25
|
+
key: string;
|
|
26
|
+
label: string;
|
|
27
|
+
costCents: number;
|
|
28
|
+
calls: number;
|
|
29
|
+
chatCalls: number;
|
|
30
|
+
inputTokens: number;
|
|
31
|
+
outputTokens: number;
|
|
32
|
+
activeUsers: number;
|
|
33
|
+
lastActiveAt: number | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface UserUsageMetric extends UsageMetricBucket {
|
|
37
|
+
ownerEmail: string;
|
|
38
|
+
chatThreads: number;
|
|
39
|
+
chatMessages: number;
|
|
40
|
+
lastChatAt: number | null;
|
|
41
|
+
topApp: string | null;
|
|
42
|
+
role: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface AppAccessMetric {
|
|
46
|
+
id: string;
|
|
47
|
+
name: string;
|
|
48
|
+
path: string;
|
|
49
|
+
status?: "ready" | "pending";
|
|
50
|
+
isDispatch: boolean;
|
|
51
|
+
accessLabel: string;
|
|
52
|
+
accessUsers: number;
|
|
53
|
+
usersWithUsage: number;
|
|
54
|
+
usageCalls: number;
|
|
55
|
+
chatCalls: number;
|
|
56
|
+
costCents: number;
|
|
57
|
+
lastActiveAt: number | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface DailyUsageMetric {
|
|
61
|
+
date: string;
|
|
62
|
+
costCents: number;
|
|
63
|
+
calls: number;
|
|
64
|
+
chatCalls: number;
|
|
65
|
+
activeUsers: number;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface RecentUsageMetric {
|
|
69
|
+
id: number;
|
|
70
|
+
createdAt: number;
|
|
71
|
+
ownerEmail: string;
|
|
72
|
+
app: string;
|
|
73
|
+
label: string;
|
|
74
|
+
model: string;
|
|
75
|
+
inputTokens: number;
|
|
76
|
+
outputTokens: number;
|
|
77
|
+
costCents: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface DispatchUsageMetrics {
|
|
81
|
+
sinceDays: number;
|
|
82
|
+
access: {
|
|
83
|
+
viewerEmail: string;
|
|
84
|
+
orgId: string | null;
|
|
85
|
+
role: string | null;
|
|
86
|
+
scope: "organization" | "solo";
|
|
87
|
+
totalUsers: number;
|
|
88
|
+
};
|
|
89
|
+
totals: {
|
|
90
|
+
costCents: number;
|
|
91
|
+
calls: number;
|
|
92
|
+
chatCalls: number;
|
|
93
|
+
inputTokens: number;
|
|
94
|
+
outputTokens: number;
|
|
95
|
+
cacheReadTokens: number;
|
|
96
|
+
cacheWriteTokens: number;
|
|
97
|
+
activeUsers: number;
|
|
98
|
+
chatThreads: number;
|
|
99
|
+
chatMessages: number;
|
|
100
|
+
workspaceApps: number;
|
|
101
|
+
};
|
|
102
|
+
byApp: UsageMetricBucket[];
|
|
103
|
+
byUser: UserUsageMetric[];
|
|
104
|
+
byLabel: UsageMetricBucket[];
|
|
105
|
+
byModel: UsageMetricBucket[];
|
|
106
|
+
daily: DailyUsageMetric[];
|
|
107
|
+
appAccess: AppAccessMetric[];
|
|
108
|
+
recent: RecentUsageMetric[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const RANGES = [7, 30, 90] as const;
|
|
112
|
+
|
|
113
|
+
function formatCost(cents: number): string {
|
|
114
|
+
if (!Number.isFinite(cents) || cents === 0) return "$0.00";
|
|
115
|
+
if (Math.abs(cents) < 1) return `${cents.toFixed(3)}¢`;
|
|
116
|
+
if (Math.abs(cents) < 100) return `${cents.toFixed(2)}¢`;
|
|
117
|
+
return (cents / 100).toLocaleString(undefined, {
|
|
118
|
+
style: "currency",
|
|
119
|
+
currency: "USD",
|
|
120
|
+
maximumFractionDigits: 2,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function formatNumber(value: number): string {
|
|
125
|
+
return new Intl.NumberFormat(undefined, {
|
|
126
|
+
notation: value >= 10_000 ? "compact" : "standard",
|
|
127
|
+
maximumFractionDigits: value >= 10_000 ? 1 : 0,
|
|
128
|
+
}).format(value);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function formatTokens(value: number): string {
|
|
132
|
+
return new Intl.NumberFormat(undefined, {
|
|
133
|
+
notation: "compact",
|
|
134
|
+
maximumFractionDigits: 1,
|
|
135
|
+
}).format(value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function timeAgo(timestamp: number | null): string {
|
|
139
|
+
if (!timestamp) return "No activity";
|
|
140
|
+
const diff = Date.now() - timestamp;
|
|
141
|
+
if (diff < 60_000) return "just now";
|
|
142
|
+
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
|
143
|
+
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
|
144
|
+
return `${Math.floor(diff / 86_400_000)}d ago`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function displayApp(value: string | null | undefined): string {
|
|
148
|
+
const trimmed = value?.trim();
|
|
149
|
+
if (!trimmed || trimmed === "unattributed") return "Unattributed";
|
|
150
|
+
return trimmed;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function maxCost(rows: Array<{ costCents: number }>): number {
|
|
154
|
+
return rows.reduce((max, row) => Math.max(max, row.costCents), 0);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function barWidth(value: number, max: number): string {
|
|
158
|
+
if (max <= 0 || value <= 0) return "0%";
|
|
159
|
+
return `${Math.max(4, Math.round((value / max) * 100))}%`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function RangeSelector({
|
|
163
|
+
value,
|
|
164
|
+
onChange,
|
|
165
|
+
}: {
|
|
166
|
+
value: number;
|
|
167
|
+
onChange: (value: number) => void;
|
|
168
|
+
}) {
|
|
169
|
+
return (
|
|
170
|
+
<div className="flex rounded-md border bg-card p-0.5">
|
|
171
|
+
{RANGES.map((range) => (
|
|
172
|
+
<Button
|
|
173
|
+
key={range}
|
|
174
|
+
type="button"
|
|
175
|
+
variant={value === range ? "secondary" : "ghost"}
|
|
176
|
+
size="sm"
|
|
177
|
+
className="h-7 px-3 text-xs"
|
|
178
|
+
onClick={() => onChange(range)}
|
|
179
|
+
>
|
|
180
|
+
{range}d
|
|
181
|
+
</Button>
|
|
182
|
+
))}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function MetricCard({
|
|
188
|
+
label,
|
|
189
|
+
value,
|
|
190
|
+
detail,
|
|
191
|
+
icon,
|
|
192
|
+
}: {
|
|
193
|
+
label: string;
|
|
194
|
+
value: string;
|
|
195
|
+
detail: string;
|
|
196
|
+
icon: ReactNode;
|
|
197
|
+
}) {
|
|
198
|
+
return (
|
|
199
|
+
<div className="rounded-lg border bg-card p-4">
|
|
200
|
+
<div className="mb-3 flex items-center justify-between gap-3">
|
|
201
|
+
<span className="text-xs font-medium text-muted-foreground">
|
|
202
|
+
{label}
|
|
203
|
+
</span>
|
|
204
|
+
<span className="text-muted-foreground">{icon}</span>
|
|
205
|
+
</div>
|
|
206
|
+
<div className="text-2xl font-semibold tabular-nums text-foreground">
|
|
207
|
+
{value}
|
|
208
|
+
</div>
|
|
209
|
+
<div className="mt-1 truncate text-xs text-muted-foreground">
|
|
210
|
+
{detail}
|
|
211
|
+
</div>
|
|
212
|
+
</div>
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function Panel({
|
|
217
|
+
title,
|
|
218
|
+
icon,
|
|
219
|
+
children,
|
|
220
|
+
action,
|
|
221
|
+
}: {
|
|
222
|
+
title: string;
|
|
223
|
+
icon: ReactNode;
|
|
224
|
+
children: ReactNode;
|
|
225
|
+
action?: ReactNode;
|
|
226
|
+
}) {
|
|
227
|
+
return (
|
|
228
|
+
<section className="rounded-lg border bg-card">
|
|
229
|
+
<div className="flex items-center justify-between gap-3 border-b px-4 py-3">
|
|
230
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
231
|
+
<span className="text-muted-foreground">{icon}</span>
|
|
232
|
+
<h2 className="truncate text-sm font-semibold text-foreground">
|
|
233
|
+
{title}
|
|
234
|
+
</h2>
|
|
235
|
+
</div>
|
|
236
|
+
{action}
|
|
237
|
+
</div>
|
|
238
|
+
<div className="p-4">{children}</div>
|
|
239
|
+
</section>
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function LoadingMetrics() {
|
|
244
|
+
return (
|
|
245
|
+
<div className="space-y-4">
|
|
246
|
+
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
|
247
|
+
{Array.from({ length: 5 }).map((_, index) => (
|
|
248
|
+
<div key={index} className="rounded-lg border bg-card p-4">
|
|
249
|
+
<Skeleton className="mb-4 h-4 w-24" />
|
|
250
|
+
<Skeleton className="h-7 w-20" />
|
|
251
|
+
<Skeleton className="mt-3 h-3 w-28" />
|
|
252
|
+
</div>
|
|
253
|
+
))}
|
|
254
|
+
</div>
|
|
255
|
+
<Skeleton className="h-80 rounded-lg" />
|
|
256
|
+
</div>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function AppSpendRows({ rows }: { rows: UsageMetricBucket[] }) {
|
|
261
|
+
const max = maxCost(rows);
|
|
262
|
+
if (rows.length === 0) {
|
|
263
|
+
return (
|
|
264
|
+
<div className="rounded-lg border border-dashed px-4 py-8 text-sm text-muted-foreground">
|
|
265
|
+
No LLM usage recorded for this window.
|
|
266
|
+
</div>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
return (
|
|
270
|
+
<div className="space-y-3">
|
|
271
|
+
{rows.map((row) => (
|
|
272
|
+
<div key={row.key} className="space-y-1.5">
|
|
273
|
+
<div className="flex items-center justify-between gap-3 text-sm">
|
|
274
|
+
<div className="min-w-0">
|
|
275
|
+
<div className="truncate font-medium text-foreground">
|
|
276
|
+
{displayApp(row.key)}
|
|
277
|
+
</div>
|
|
278
|
+
<div className="text-xs text-muted-foreground">
|
|
279
|
+
{formatNumber(row.chatCalls)} chats ·{" "}
|
|
280
|
+
{formatNumber(row.activeUsers)} users
|
|
281
|
+
</div>
|
|
282
|
+
</div>
|
|
283
|
+
<div className="shrink-0 text-right">
|
|
284
|
+
<div className="font-medium tabular-nums text-foreground">
|
|
285
|
+
{formatCost(row.costCents)}
|
|
286
|
+
</div>
|
|
287
|
+
<div className="text-xs text-muted-foreground">
|
|
288
|
+
{formatNumber(row.calls)} calls
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
|
293
|
+
<div
|
|
294
|
+
className="h-full rounded-full bg-foreground"
|
|
295
|
+
style={{ width: barWidth(row.costCents, max) }}
|
|
296
|
+
/>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
))}
|
|
300
|
+
</div>
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function DailyActivity({ rows }: { rows: DailyUsageMetric[] }) {
|
|
305
|
+
const max = Math.max(
|
|
306
|
+
1,
|
|
307
|
+
rows.reduce((value, row) => Math.max(value, row.calls), 0),
|
|
308
|
+
);
|
|
309
|
+
if (rows.length === 0) {
|
|
310
|
+
return (
|
|
311
|
+
<div className="rounded-lg border border-dashed px-4 py-8 text-sm text-muted-foreground">
|
|
312
|
+
No activity in this window.
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
return (
|
|
317
|
+
<div className="flex h-44 items-end gap-1">
|
|
318
|
+
{rows.map((row) => (
|
|
319
|
+
<div
|
|
320
|
+
key={row.date}
|
|
321
|
+
className="group flex min-w-0 flex-1 flex-col items-center gap-2"
|
|
322
|
+
>
|
|
323
|
+
<div className="relative flex h-36 w-full items-end rounded-sm bg-muted/60">
|
|
324
|
+
<div
|
|
325
|
+
className="w-full rounded-sm bg-foreground transition group-hover:bg-primary"
|
|
326
|
+
style={{ height: `${Math.max(4, (row.calls / max) * 100)}%` }}
|
|
327
|
+
/>
|
|
328
|
+
</div>
|
|
329
|
+
<div className="hidden text-[10px] text-muted-foreground sm:block">
|
|
330
|
+
{row.date.slice(5)}
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
))}
|
|
334
|
+
</div>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function AppAccessTable({ rows }: { rows: AppAccessMetric[] }) {
|
|
339
|
+
const visibleRows = rows.filter((row) => !row.isDispatch);
|
|
340
|
+
if (visibleRows.length === 0) {
|
|
341
|
+
return (
|
|
342
|
+
<div className="rounded-lg border border-dashed px-4 py-8 text-sm text-muted-foreground">
|
|
343
|
+
No workspace apps discovered yet.
|
|
344
|
+
</div>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
return (
|
|
348
|
+
<div className="overflow-x-auto">
|
|
349
|
+
<table className="w-full min-w-[720px] text-left text-xs">
|
|
350
|
+
<thead>
|
|
351
|
+
<tr className="border-b text-muted-foreground">
|
|
352
|
+
<th className="px-2 py-2 font-medium">App</th>
|
|
353
|
+
<th className="px-2 py-2 font-medium">Access</th>
|
|
354
|
+
<th className="px-2 py-2 text-right font-medium">Users</th>
|
|
355
|
+
<th className="px-2 py-2 text-right font-medium">Chats</th>
|
|
356
|
+
<th className="px-2 py-2 text-right font-medium">Cost</th>
|
|
357
|
+
<th className="px-2 py-2 text-right font-medium">Last activity</th>
|
|
358
|
+
</tr>
|
|
359
|
+
</thead>
|
|
360
|
+
<tbody>
|
|
361
|
+
{visibleRows.map((row) => (
|
|
362
|
+
<tr key={row.id} className="border-b last:border-0">
|
|
363
|
+
<td className="px-2 py-3">
|
|
364
|
+
<div className="font-medium text-foreground">{row.name}</div>
|
|
365
|
+
<div className="font-mono text-[11px] text-muted-foreground">
|
|
366
|
+
{row.path}
|
|
367
|
+
</div>
|
|
368
|
+
</td>
|
|
369
|
+
<td className="px-2 py-3">
|
|
370
|
+
<Badge
|
|
371
|
+
variant="outline"
|
|
372
|
+
className={cn(
|
|
373
|
+
row.status === "pending" &&
|
|
374
|
+
"border-amber-500/30 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
|
375
|
+
)}
|
|
376
|
+
>
|
|
377
|
+
{row.status === "pending" ? "Building" : row.accessLabel}
|
|
378
|
+
</Badge>
|
|
379
|
+
</td>
|
|
380
|
+
<td className="px-2 py-3 text-right tabular-nums">
|
|
381
|
+
{formatNumber(row.usersWithUsage)} /{" "}
|
|
382
|
+
{formatNumber(row.accessUsers)}
|
|
383
|
+
</td>
|
|
384
|
+
<td className="px-2 py-3 text-right tabular-nums">
|
|
385
|
+
{formatNumber(row.chatCalls)}
|
|
386
|
+
</td>
|
|
387
|
+
<td className="px-2 py-3 text-right tabular-nums">
|
|
388
|
+
{formatCost(row.costCents)}
|
|
389
|
+
</td>
|
|
390
|
+
<td className="px-2 py-3 text-right text-muted-foreground">
|
|
391
|
+
{timeAgo(row.lastActiveAt)}
|
|
392
|
+
</td>
|
|
393
|
+
</tr>
|
|
394
|
+
))}
|
|
395
|
+
</tbody>
|
|
396
|
+
</table>
|
|
397
|
+
</div>
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function UserTable({ rows }: { rows: UserUsageMetric[] }) {
|
|
402
|
+
if (rows.length === 0) {
|
|
403
|
+
return (
|
|
404
|
+
<div className="rounded-lg border border-dashed px-4 py-8 text-sm text-muted-foreground">
|
|
405
|
+
No users have triggered LLM usage in this window.
|
|
406
|
+
</div>
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
return (
|
|
410
|
+
<div className="overflow-x-auto">
|
|
411
|
+
<table className="w-full min-w-[760px] text-left text-xs">
|
|
412
|
+
<thead>
|
|
413
|
+
<tr className="border-b text-muted-foreground">
|
|
414
|
+
<th className="px-2 py-2 font-medium">User</th>
|
|
415
|
+
<th className="px-2 py-2 font-medium">Role</th>
|
|
416
|
+
<th className="px-2 py-2 font-medium">Top app</th>
|
|
417
|
+
<th className="px-2 py-2 text-right font-medium">Chats</th>
|
|
418
|
+
<th className="px-2 py-2 text-right font-medium">Threads</th>
|
|
419
|
+
<th className="px-2 py-2 text-right font-medium">Tokens</th>
|
|
420
|
+
<th className="px-2 py-2 text-right font-medium">Cost</th>
|
|
421
|
+
</tr>
|
|
422
|
+
</thead>
|
|
423
|
+
<tbody>
|
|
424
|
+
{rows.slice(0, 12).map((row) => (
|
|
425
|
+
<tr key={row.ownerEmail} className="border-b last:border-0">
|
|
426
|
+
<td className="max-w-64 px-2 py-3">
|
|
427
|
+
<div className="truncate font-medium text-foreground">
|
|
428
|
+
{row.ownerEmail}
|
|
429
|
+
</div>
|
|
430
|
+
<div className="text-muted-foreground">
|
|
431
|
+
{timeAgo(row.lastActiveAt ?? row.lastChatAt)}
|
|
432
|
+
</div>
|
|
433
|
+
</td>
|
|
434
|
+
<td className="px-2 py-3">
|
|
435
|
+
<Badge variant="secondary">{row.role ?? "user"}</Badge>
|
|
436
|
+
</td>
|
|
437
|
+
<td className="px-2 py-3 text-muted-foreground">
|
|
438
|
+
{displayApp(row.topApp)}
|
|
439
|
+
</td>
|
|
440
|
+
<td className="px-2 py-3 text-right tabular-nums">
|
|
441
|
+
{formatNumber(row.chatCalls)}
|
|
442
|
+
</td>
|
|
443
|
+
<td className="px-2 py-3 text-right tabular-nums">
|
|
444
|
+
{formatNumber(row.chatThreads)}
|
|
445
|
+
</td>
|
|
446
|
+
<td className="px-2 py-3 text-right tabular-nums">
|
|
447
|
+
{formatTokens(row.inputTokens + row.outputTokens)}
|
|
448
|
+
</td>
|
|
449
|
+
<td className="px-2 py-3 text-right tabular-nums">
|
|
450
|
+
{formatCost(row.costCents)}
|
|
451
|
+
</td>
|
|
452
|
+
</tr>
|
|
453
|
+
))}
|
|
454
|
+
</tbody>
|
|
455
|
+
</table>
|
|
456
|
+
</div>
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function CompactBreakdown({
|
|
461
|
+
rows,
|
|
462
|
+
empty,
|
|
463
|
+
}: {
|
|
464
|
+
rows: UsageMetricBucket[];
|
|
465
|
+
empty: string;
|
|
466
|
+
}) {
|
|
467
|
+
const max = maxCost(rows);
|
|
468
|
+
if (rows.length === 0) {
|
|
469
|
+
return <div className="text-sm text-muted-foreground">{empty}</div>;
|
|
470
|
+
}
|
|
471
|
+
return (
|
|
472
|
+
<div className="space-y-3">
|
|
473
|
+
{rows.slice(0, 6).map((row) => (
|
|
474
|
+
<div key={row.key} className="space-y-1">
|
|
475
|
+
<div className="flex items-center justify-between gap-3 text-xs">
|
|
476
|
+
<span className="truncate font-medium text-foreground">
|
|
477
|
+
{row.label}
|
|
478
|
+
</span>
|
|
479
|
+
<span className="shrink-0 tabular-nums text-muted-foreground">
|
|
480
|
+
{formatCost(row.costCents)}
|
|
481
|
+
</span>
|
|
482
|
+
</div>
|
|
483
|
+
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
|
|
484
|
+
<div
|
|
485
|
+
className="h-full rounded-full bg-muted-foreground"
|
|
486
|
+
style={{ width: barWidth(row.costCents, max) }}
|
|
487
|
+
/>
|
|
488
|
+
</div>
|
|
489
|
+
</div>
|
|
490
|
+
))}
|
|
491
|
+
</div>
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function RecentTable({ rows }: { rows: RecentUsageMetric[] }) {
|
|
496
|
+
if (rows.length === 0) {
|
|
497
|
+
return (
|
|
498
|
+
<div className="rounded-lg border border-dashed px-4 py-8 text-sm text-muted-foreground">
|
|
499
|
+
No recent LLM calls.
|
|
500
|
+
</div>
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
return (
|
|
504
|
+
<div className="overflow-x-auto">
|
|
505
|
+
<table className="w-full min-w-[760px] text-left text-xs">
|
|
506
|
+
<thead>
|
|
507
|
+
<tr className="border-b text-muted-foreground">
|
|
508
|
+
<th className="px-2 py-2 font-medium">When</th>
|
|
509
|
+
<th className="px-2 py-2 font-medium">User</th>
|
|
510
|
+
<th className="px-2 py-2 font-medium">App</th>
|
|
511
|
+
<th className="px-2 py-2 font-medium">Label</th>
|
|
512
|
+
<th className="px-2 py-2 font-medium">Model</th>
|
|
513
|
+
<th className="px-2 py-2 text-right font-medium">Cost</th>
|
|
514
|
+
</tr>
|
|
515
|
+
</thead>
|
|
516
|
+
<tbody>
|
|
517
|
+
{rows.slice(0, 10).map((row) => (
|
|
518
|
+
<tr key={row.id} className="border-b last:border-0">
|
|
519
|
+
<td className="px-2 py-3 text-muted-foreground">
|
|
520
|
+
{timeAgo(row.createdAt)}
|
|
521
|
+
</td>
|
|
522
|
+
<td className="max-w-56 px-2 py-3">
|
|
523
|
+
<div className="truncate text-foreground">{row.ownerEmail}</div>
|
|
524
|
+
</td>
|
|
525
|
+
<td className="px-2 py-3 text-muted-foreground">
|
|
526
|
+
{displayApp(row.app)}
|
|
527
|
+
</td>
|
|
528
|
+
<td className="px-2 py-3">
|
|
529
|
+
<Badge variant="outline">{row.label}</Badge>
|
|
530
|
+
</td>
|
|
531
|
+
<td className="max-w-48 px-2 py-3">
|
|
532
|
+
<div className="truncate text-muted-foreground">
|
|
533
|
+
{row.model}
|
|
534
|
+
</div>
|
|
535
|
+
</td>
|
|
536
|
+
<td className="px-2 py-3 text-right tabular-nums">
|
|
537
|
+
{formatCost(row.costCents)}
|
|
538
|
+
</td>
|
|
539
|
+
</tr>
|
|
540
|
+
))}
|
|
541
|
+
</tbody>
|
|
542
|
+
</table>
|
|
543
|
+
</div>
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export default function MetricsRoute() {
|
|
548
|
+
const [sinceDays, setSinceDays] = useState(30);
|
|
549
|
+
const { data, isLoading, error } = useActionQuery(
|
|
550
|
+
"list-dispatch-usage-metrics",
|
|
551
|
+
{ sinceDays },
|
|
552
|
+
{ refetchInterval: 30_000 },
|
|
553
|
+
);
|
|
554
|
+
const metrics = data as DispatchUsageMetrics | undefined;
|
|
555
|
+
const totalTokens = useMemo(() => {
|
|
556
|
+
if (!metrics) return 0;
|
|
557
|
+
return (
|
|
558
|
+
metrics.totals.inputTokens +
|
|
559
|
+
metrics.totals.outputTokens +
|
|
560
|
+
metrics.totals.cacheReadTokens +
|
|
561
|
+
metrics.totals.cacheWriteTokens
|
|
562
|
+
);
|
|
563
|
+
}, [metrics]);
|
|
564
|
+
|
|
565
|
+
return (
|
|
566
|
+
<DispatchShell
|
|
567
|
+
title="Metrics"
|
|
568
|
+
description="Workspace-wide LLM spend, chat volume, user activity, and app access."
|
|
569
|
+
>
|
|
570
|
+
<div className="space-y-4">
|
|
571
|
+
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
572
|
+
<div className="text-sm text-muted-foreground">
|
|
573
|
+
{metrics?.access.scope === "organization"
|
|
574
|
+
? `${metrics.access.totalUsers} workspace users`
|
|
575
|
+
: `${metrics?.access.totalUsers ?? 0} signed-in users`}
|
|
576
|
+
</div>
|
|
577
|
+
<RangeSelector value={sinceDays} onChange={setSinceDays} />
|
|
578
|
+
</div>
|
|
579
|
+
|
|
580
|
+
{error ? (
|
|
581
|
+
<Alert variant="destructive">
|
|
582
|
+
<IconAlertTriangle className="h-4 w-4" />
|
|
583
|
+
<AlertTitle>Metrics unavailable</AlertTitle>
|
|
584
|
+
<AlertDescription>
|
|
585
|
+
{error instanceof Error ? error.message : "Unable to load usage."}
|
|
586
|
+
</AlertDescription>
|
|
587
|
+
</Alert>
|
|
588
|
+
) : null}
|
|
589
|
+
|
|
590
|
+
{isLoading && !metrics ? <LoadingMetrics /> : null}
|
|
591
|
+
|
|
592
|
+
{metrics ? (
|
|
593
|
+
<>
|
|
594
|
+
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
|
|
595
|
+
<MetricCard
|
|
596
|
+
label="Estimated spend"
|
|
597
|
+
value={formatCost(metrics.totals.costCents)}
|
|
598
|
+
detail={`${formatTokens(totalTokens)} total tokens`}
|
|
599
|
+
icon={<IconCoin size={17} />}
|
|
600
|
+
/>
|
|
601
|
+
<MetricCard
|
|
602
|
+
label="LLM calls"
|
|
603
|
+
value={formatNumber(metrics.totals.calls)}
|
|
604
|
+
detail={`${formatNumber(metrics.totals.chatCalls)} chat turns`}
|
|
605
|
+
icon={<IconActivity size={17} />}
|
|
606
|
+
/>
|
|
607
|
+
<MetricCard
|
|
608
|
+
label="Active users"
|
|
609
|
+
value={formatNumber(metrics.totals.activeUsers)}
|
|
610
|
+
detail={`${formatNumber(metrics.access.totalUsers)} users with access`}
|
|
611
|
+
icon={<IconUsersGroup size={17} />}
|
|
612
|
+
/>
|
|
613
|
+
<MetricCard
|
|
614
|
+
label="Workspace apps"
|
|
615
|
+
value={formatNumber(metrics.totals.workspaceApps)}
|
|
616
|
+
detail={`${formatNumber(metrics.byApp.length)} with usage`}
|
|
617
|
+
icon={<IconApps size={17} />}
|
|
618
|
+
/>
|
|
619
|
+
<MetricCard
|
|
620
|
+
label="Chat threads"
|
|
621
|
+
value={formatNumber(metrics.totals.chatThreads)}
|
|
622
|
+
detail={`${formatNumber(metrics.totals.chatMessages)} messages`}
|
|
623
|
+
icon={<IconMessages size={17} />}
|
|
624
|
+
/>
|
|
625
|
+
</div>
|
|
626
|
+
|
|
627
|
+
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.65fr)]">
|
|
628
|
+
<Panel title="Spend By App" icon={<IconChartBar size={16} />}>
|
|
629
|
+
<AppSpendRows rows={metrics.byApp} />
|
|
630
|
+
</Panel>
|
|
631
|
+
<Panel title="Daily Activity" icon={<IconClockHour4 size={16} />}>
|
|
632
|
+
<DailyActivity rows={metrics.daily} />
|
|
633
|
+
</Panel>
|
|
634
|
+
</div>
|
|
635
|
+
|
|
636
|
+
<Panel title="Access By App" icon={<IconApps size={16} />}>
|
|
637
|
+
<AppAccessTable rows={metrics.appAccess} />
|
|
638
|
+
</Panel>
|
|
639
|
+
|
|
640
|
+
<Panel title="Users" icon={<IconUsersGroup size={16} />}>
|
|
641
|
+
<UserTable rows={metrics.byUser} />
|
|
642
|
+
</Panel>
|
|
643
|
+
|
|
644
|
+
<div className="grid gap-4 lg:grid-cols-2">
|
|
645
|
+
<Panel title="Models" icon={<IconChartBar size={16} />}>
|
|
646
|
+
<CompactBreakdown
|
|
647
|
+
rows={metrics.byModel}
|
|
648
|
+
empty="No model usage in this window."
|
|
649
|
+
/>
|
|
650
|
+
</Panel>
|
|
651
|
+
<Panel title="Work Types" icon={<IconActivity size={16} />}>
|
|
652
|
+
<CompactBreakdown
|
|
653
|
+
rows={metrics.byLabel}
|
|
654
|
+
empty="No labeled usage in this window."
|
|
655
|
+
/>
|
|
656
|
+
</Panel>
|
|
657
|
+
</div>
|
|
658
|
+
|
|
659
|
+
<Panel title="Recent LLM Calls" icon={<IconActivity size={16} />}>
|
|
660
|
+
<RecentTable rows={metrics.recent} />
|
|
661
|
+
</Panel>
|
|
662
|
+
</>
|
|
663
|
+
) : null}
|
|
664
|
+
</div>
|
|
665
|
+
</DispatchShell>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
@@ -2,9 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
|
|
2
2
|
import { Link } from "react-router";
|
|
3
3
|
import {
|
|
4
4
|
PromptComposer,
|
|
5
|
-
isInBuilderFrame,
|
|
6
5
|
sendToAgentChat,
|
|
7
|
-
sendToBuilderChat,
|
|
8
6
|
useActionQuery,
|
|
9
7
|
useChatModels,
|
|
10
8
|
agentNativePath,
|
|
@@ -101,14 +99,6 @@ function HomeChatPanel() {
|
|
|
101
99
|
const { selectedModel } = useChatModels();
|
|
102
100
|
|
|
103
101
|
const send = (message: string) => {
|
|
104
|
-
// When mounted inside builder.io's webview, the parent frame is the
|
|
105
|
-
// code-writing agent. Route home-chat submissions up to Builder's chat
|
|
106
|
-
// instead of the local dispatch agent — the local sidebar is in this
|
|
107
|
-
// same iframe and would never receive a postMessage we send to parent.
|
|
108
|
-
if (isInBuilderFrame()) {
|
|
109
|
-
sendToBuilderChat({ message, submit: true });
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
102
|
sendToAgentChat({
|
|
113
103
|
message,
|
|
114
104
|
submit: true,
|