@oh-my-pi/omp-stats 18.3.5 → 18.4.0
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/CHANGELOG.md +16 -0
- package/README.md +13 -11
- package/build.ts +5 -49
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/index.js +105 -126
- package/dist/types/aggregator.d.ts +25 -22
- package/dist/types/client/api.d.ts +11 -7
- package/dist/types/client/app/LiveChip.d.ts +2 -0
- package/dist/types/client/app/Shell.d.ts +16 -0
- package/dist/types/client/app/ThemeToggle.d.ts +1 -0
- package/dist/types/client/app/nav.d.ts +18 -0
- package/dist/types/client/charts/BarList.d.ts +17 -0
- package/dist/types/client/charts/Chart.d.ts +49 -0
- package/dist/types/client/charts/Legend.d.ts +16 -0
- package/dist/types/client/charts/ShareBar.d.ts +11 -0
- package/dist/types/client/charts/Sparkline.d.ts +11 -0
- package/dist/types/client/charts/TimeChart.d.ts +9 -0
- package/dist/types/client/charts/index.d.ts +7 -0
- package/dist/types/client/charts/types.d.ts +25 -0
- package/dist/types/client/charts/useWidth.d.ts +3 -0
- package/dist/types/client/data/colors.d.ts +25 -0
- package/dist/types/client/data/formatters.d.ts +9 -1
- package/dist/types/client/data/live.d.ts +29 -0
- package/dist/types/client/data/query.d.ts +35 -0
- package/dist/types/client/data/range.d.ts +31 -0
- package/dist/types/client/data/series.d.ts +28 -0
- package/dist/types/client/data/useHashRoute.d.ts +18 -4
- package/dist/types/client/data/view-models.d.ts +111 -28
- package/dist/types/client/index.d.ts +0 -1
- package/dist/types/client/routes/CostsRoute.d.ts +2 -2
- package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
- package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
- package/dist/types/client/routes/GainRoute.d.ts +1 -2
- package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
- package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
- package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
- package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
- package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
- package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
- package/dist/types/client/routes/TracesRoute.d.ts +2 -2
- package/dist/types/client/routes/index.d.ts +1 -1
- package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
- package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
- package/dist/types/client/traces/trace-colors.d.ts +18 -12
- package/dist/types/client/ui/Badge.d.ts +19 -0
- package/dist/types/client/ui/Card.d.ts +25 -0
- package/dist/types/client/ui/Drawer.d.ts +25 -0
- package/dist/types/client/ui/JsonBlock.d.ts +2 -2
- package/dist/types/client/ui/Modal.d.ts +23 -0
- package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
- package/dist/types/client/ui/SearchInput.d.ts +8 -0
- package/dist/types/client/ui/Segmented.d.ts +15 -0
- package/dist/types/client/ui/Stat.d.ts +36 -0
- package/dist/types/client/ui/States.d.ts +41 -0
- package/dist/types/client/ui/Table.d.ts +48 -0
- package/dist/types/client/ui/index.d.ts +9 -10
- package/dist/types/db.d.ts +90 -106
- package/dist/types/frustration.d.ts +70 -0
- package/dist/types/index.d.ts +3 -1
- package/dist/types/live.d.ts +30 -0
- package/dist/types/rollup.d.ts +115 -0
- package/dist/types/server.d.ts +6 -1
- package/dist/types/shared-types.d.ts +116 -56
- package/dist/types/types.d.ts +4 -0
- package/dist/types/user-metrics.d.ts +8 -0
- package/package.json +7 -13
- package/src/aggregator.ts +276 -211
- package/src/client/App.tsx +38 -80
- package/src/client/api.ts +65 -7
- package/src/client/app/LiveChip.tsx +97 -0
- package/src/client/app/Shell.tsx +181 -0
- package/src/client/app/ThemeToggle.tsx +5 -2
- package/src/client/app/nav.ts +78 -0
- package/src/client/charts/BarList.tsx +48 -0
- package/src/client/charts/Chart.tsx +388 -0
- package/src/client/charts/Legend.tsx +61 -0
- package/src/client/charts/ShareBar.tsx +26 -0
- package/src/client/charts/Sparkline.tsx +38 -0
- package/src/client/charts/TimeChart.tsx +22 -0
- package/src/client/charts/index.ts +7 -0
- package/src/client/charts/types.ts +27 -0
- package/src/client/charts/useWidth.ts +19 -0
- package/src/client/data/colors.ts +45 -0
- package/src/client/data/formatters.ts +30 -9
- package/src/client/data/live.tsx +85 -0
- package/src/client/data/query.ts +166 -0
- package/src/client/data/range.ts +82 -0
- package/src/client/data/series.ts +85 -0
- package/src/client/data/useHashRoute.ts +41 -83
- package/src/client/data/view-models.ts +347 -140
- package/src/client/index.tsx +3 -6
- package/src/client/routes/CostsRoute.tsx +394 -222
- package/src/client/routes/ErrorsRoute.tsx +401 -98
- package/src/client/routes/FrustrationRoute.tsx +924 -0
- package/src/client/routes/GainRoute.tsx +209 -206
- package/src/client/routes/ModelsRoute.tsx +540 -431
- package/src/client/routes/OverviewRoute.tsx +283 -311
- package/src/client/routes/ProjectsRoute.tsx +298 -156
- package/src/client/routes/ProvidersRoute.tsx +948 -416
- package/src/client/routes/RequestsRoute.tsx +295 -107
- package/src/client/routes/ToolsRoute.tsx +495 -398
- package/src/client/routes/TracesRoute.tsx +136 -117
- package/src/client/routes/costs.css +24 -0
- package/src/client/routes/errors.css +60 -0
- package/src/client/routes/frustration.css +123 -0
- package/src/client/routes/index.ts +1 -1
- package/src/client/routes/models.css +43 -0
- package/src/client/routes/projects.css +6 -0
- package/src/client/routes/providers.css +28 -0
- package/src/client/routes/tools.css +14 -0
- package/src/client/styles.css +1396 -1299
- package/src/client/traces/AggregatesPanel.tsx +55 -74
- package/src/client/traces/Minimap.tsx +6 -15
- package/src/client/traces/SpanDrawer.tsx +128 -194
- package/src/client/traces/SummaryStrip.tsx +21 -24
- package/src/client/traces/TimelineCanvas.tsx +27 -44
- package/src/client/traces/TraceView.tsx +175 -130
- package/src/client/traces/TranscriptList.tsx +13 -41
- package/src/client/traces/trace-colors.ts +60 -53
- package/src/client/traces/traces.css +279 -0
- package/src/client/ui/Badge.tsx +29 -0
- package/src/client/ui/Card.tsx +61 -0
- package/src/client/ui/Drawer.tsx +93 -0
- package/src/client/ui/JsonBlock.tsx +26 -44
- package/src/client/ui/Modal.tsx +106 -0
- package/src/client/ui/RequestDrawer.tsx +163 -188
- package/src/client/ui/SearchInput.tsx +25 -0
- package/src/client/ui/Segmented.tsx +61 -0
- package/src/client/ui/Stat.tsx +104 -0
- package/src/client/ui/States.tsx +81 -0
- package/src/client/ui/Table.tsx +229 -0
- package/src/client/ui/index.ts +9 -10
- package/src/client/ui/modal.css +54 -0
- package/src/client/ui/request-drawer.css +40 -0
- package/src/db.ts +379 -983
- package/src/frustration.ts +473 -0
- package/src/index.ts +22 -22
- package/src/live.ts +259 -0
- package/src/parser.ts +55 -66
- package/src/rollup.ts +951 -0
- package/src/server.ts +123 -40
- package/src/shared-types.ts +120 -56
- package/src/trace.ts +14 -7
- package/src/types.ts +4 -0
- package/src/user-metrics.ts +13 -0
- package/dist/client/styles.css +0 -1976
- package/dist/types/client/app/AppLayout.d.ts +0 -16
- package/dist/types/client/app/NavRail.d.ts +0 -7
- package/dist/types/client/app/RangeControl.d.ts +0 -7
- package/dist/types/client/app/SyncButton.d.ts +0 -14
- package/dist/types/client/app/TopBar.d.ts +0 -15
- package/dist/types/client/app/routes.d.ts +0 -12
- package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
- package/dist/types/client/components/chart-shared.d.ts +0 -178
- package/dist/types/client/components/models-table-shared.d.ts +0 -175
- package/dist/types/client/components/range-meta.d.ts +0 -21
- package/dist/types/client/data/charts.d.ts +0 -1
- package/dist/types/client/data/useResource.d.ts +0 -13
- package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
- package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
- package/dist/types/client/ui/DataTable.d.ts +0 -17
- package/dist/types/client/ui/EmptyState.d.ts +0 -7
- package/dist/types/client/ui/ErrorState.d.ts +0 -6
- package/dist/types/client/ui/MetricCluster.d.ts +0 -5
- package/dist/types/client/ui/Panel.d.ts +0 -7
- package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
- package/dist/types/client/ui/Skeleton.d.ts +0 -8
- package/dist/types/client/ui/StatusPill.d.ts +0 -7
- package/src/client/app/AppLayout.tsx +0 -93
- package/src/client/app/NavRail.tsx +0 -44
- package/src/client/app/RangeControl.tsx +0 -39
- package/src/client/app/SyncButton.tsx +0 -75
- package/src/client/app/TopBar.tsx +0 -73
- package/src/client/app/routes.ts +0 -93
- package/src/client/components/AgentTokenShare.tsx +0 -68
- package/src/client/components/chart-shared.tsx +0 -273
- package/src/client/components/models-table-shared.tsx +0 -255
- package/src/client/components/range-meta.ts +0 -73
- package/src/client/data/charts.ts +0 -14
- package/src/client/data/useResource.ts +0 -154
- package/src/client/routes/BehaviorRoute.tsx +0 -623
- package/src/client/ui/AsyncBoundary.tsx +0 -54
- package/src/client/ui/DataTable.tsx +0 -122
- package/src/client/ui/EmptyState.tsx +0 -16
- package/src/client/ui/ErrorState.tsx +0 -25
- package/src/client/ui/MetricCluster.tsx +0 -92
- package/src/client/ui/Panel.tsx +0 -24
- package/src/client/ui/SegmentedControl.tsx +0 -36
- package/src/client/ui/Skeleton.tsx +0 -17
- package/src/client/ui/StatusPill.tsx +0 -15
- package/tailwind.config.js +0 -40
package/src/client/App.tsx
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { useCallback, useRef, useState } from "react";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
2
|
+
import type { DashboardSection } from "./app/nav";
|
|
3
|
+
import { Shell } from "./app/Shell";
|
|
4
|
+
import { LiveProvider } from "./data/live";
|
|
4
5
|
import { useHashRoute } from "./data/useHashRoute";
|
|
5
6
|
import {
|
|
6
|
-
BehaviorRoute,
|
|
7
7
|
CostsRoute,
|
|
8
8
|
ErrorsRoute,
|
|
9
|
+
FrustrationRoute,
|
|
9
10
|
GainRoute,
|
|
10
11
|
ModelsRoute,
|
|
11
12
|
OverviewRoute,
|
|
@@ -18,104 +19,61 @@ import {
|
|
|
18
19
|
import { RequestDrawer } from "./ui/RequestDrawer";
|
|
19
20
|
|
|
20
21
|
export default function App() {
|
|
22
|
+
return (
|
|
23
|
+
<LiveProvider>
|
|
24
|
+
<Dashboard />
|
|
25
|
+
</LiveProvider>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function Dashboard() {
|
|
21
30
|
const { section, setSection, range, setRange, session, setSession } = useHashRoute();
|
|
22
|
-
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
23
31
|
const [selectedRequestId, setSelectedRequestId] = useState<number | null>(null);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const handleSyncComplete = useCallback((result: { success: boolean }) => {
|
|
27
|
-
if (result.success) {
|
|
28
|
-
setRefreshTrigger(prev => prev + 1);
|
|
29
|
-
setUpdatedAt(Date.now());
|
|
30
|
-
}
|
|
31
|
-
}, []);
|
|
32
|
-
|
|
33
|
-
// Stable identity so the drawer's effects don't tear down on every App render.
|
|
32
|
+
// Stable identity so the drawer's effects don't tear down on every render.
|
|
34
33
|
const closeDrawer = useCallback(() => setSelectedRequestId(null), []);
|
|
35
34
|
|
|
36
|
-
|
|
35
|
+
// Visited pages stay mounted (hidden) so revisits are instant and keep their
|
|
36
|
+
// scroll-independent UI state; only the active page fetches (`active`).
|
|
37
|
+
const mounted = useRef(new Set<DashboardSection>());
|
|
38
|
+
mounted.current.add(section);
|
|
37
39
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
// flicker); keeping it alive makes revisits instant while the live chart
|
|
41
|
-
// instances still animate in place on data/range updates. Only the active
|
|
42
|
-
// route fetches/polls (enabled), so hidden routes don't keep hitting the API.
|
|
43
|
-
const mountedRef = useRef<Set<DashboardSection>>(new Set());
|
|
44
|
-
mountedRef.current.add(active);
|
|
45
|
-
|
|
46
|
-
const renderRoute = (target: DashboardSection) => {
|
|
47
|
-
const isActive = target === active;
|
|
40
|
+
const render = (target: DashboardSection) => {
|
|
41
|
+
const active = target === section;
|
|
48
42
|
switch (target) {
|
|
49
43
|
case "overview":
|
|
50
|
-
return
|
|
51
|
-
<OverviewRoute
|
|
52
|
-
active={isActive}
|
|
53
|
-
range={range}
|
|
54
|
-
refreshTrigger={refreshTrigger}
|
|
55
|
-
onRequestClick={setSelectedRequestId}
|
|
56
|
-
/>
|
|
57
|
-
);
|
|
44
|
+
return <OverviewRoute active={active} range={range} onRequestClick={setSelectedRequestId} />;
|
|
58
45
|
case "requests":
|
|
59
|
-
return
|
|
60
|
-
<RequestsRoute
|
|
61
|
-
active={isActive}
|
|
62
|
-
range={range}
|
|
63
|
-
refreshTrigger={refreshTrigger}
|
|
64
|
-
onRequestClick={setSelectedRequestId}
|
|
65
|
-
/>
|
|
66
|
-
);
|
|
67
|
-
case "traces":
|
|
68
|
-
return (
|
|
69
|
-
<TracesRoute
|
|
70
|
-
active={isActive}
|
|
71
|
-
session={session}
|
|
72
|
-
onOpenSession={setSession}
|
|
73
|
-
refreshTrigger={refreshTrigger}
|
|
74
|
-
/>
|
|
75
|
-
);
|
|
46
|
+
return <RequestsRoute active={active} range={range} onRequestClick={setSelectedRequestId} />;
|
|
76
47
|
case "errors":
|
|
77
|
-
return
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
range={range}
|
|
81
|
-
refreshTrigger={refreshTrigger}
|
|
82
|
-
onRequestClick={setSelectedRequestId}
|
|
83
|
-
/>
|
|
84
|
-
);
|
|
48
|
+
return <ErrorsRoute active={active} range={range} onRequestClick={setSelectedRequestId} />;
|
|
49
|
+
case "traces":
|
|
50
|
+
return <TracesRoute active={active} session={session} onOpenSession={setSession} />;
|
|
85
51
|
case "models":
|
|
86
|
-
return <ModelsRoute active={
|
|
52
|
+
return <ModelsRoute active={active} range={range} />;
|
|
87
53
|
case "providers":
|
|
88
|
-
return <ProvidersRoute active={
|
|
89
|
-
case "tools":
|
|
90
|
-
return <ToolsRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
|
|
54
|
+
return <ProvidersRoute active={active} range={range} />;
|
|
91
55
|
case "costs":
|
|
92
|
-
return <CostsRoute active={
|
|
93
|
-
case "
|
|
94
|
-
return <
|
|
56
|
+
return <CostsRoute active={active} range={range} />;
|
|
57
|
+
case "tools":
|
|
58
|
+
return <ToolsRoute active={active} range={range} />;
|
|
59
|
+
case "frustration":
|
|
60
|
+
return <FrustrationRoute active={active} range={range} />;
|
|
95
61
|
case "projects":
|
|
96
|
-
return <ProjectsRoute active={
|
|
62
|
+
return <ProjectsRoute active={active} range={range} />;
|
|
97
63
|
case "gain":
|
|
98
|
-
return <GainRoute active={
|
|
64
|
+
return <GainRoute active={active} range={range} />;
|
|
99
65
|
}
|
|
100
66
|
};
|
|
101
67
|
|
|
102
68
|
return (
|
|
103
69
|
<>
|
|
104
|
-
<
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
onRangeChange={setRange}
|
|
109
|
-
updatedAt={updatedAt}
|
|
110
|
-
onSyncComplete={handleSyncComplete}
|
|
111
|
-
>
|
|
112
|
-
{[...mountedRef.current].map(target => (
|
|
113
|
-
<div key={target} hidden={target !== active}>
|
|
114
|
-
{renderRoute(target)}
|
|
70
|
+
<Shell section={section} onSectionChange={setSection} range={range} onRangeChange={setRange}>
|
|
71
|
+
{[...mounted.current].map(target => (
|
|
72
|
+
<div key={target} hidden={target !== section}>
|
|
73
|
+
{render(target)}
|
|
115
74
|
</div>
|
|
116
75
|
))}
|
|
117
|
-
</
|
|
118
|
-
|
|
76
|
+
</Shell>
|
|
119
77
|
<RequestDrawer id={selectedRequestId} onClose={closeDrawer} />
|
|
120
78
|
</>
|
|
121
79
|
);
|
package/src/client/api.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
BehaviorDashboardStats,
|
|
3
2
|
CostDashboardStats,
|
|
4
3
|
FolderStats,
|
|
4
|
+
FrustrationDashboardStats,
|
|
5
|
+
FrustrationEstimate,
|
|
6
|
+
FrustrationJobStatus,
|
|
5
7
|
GainDashboardStats,
|
|
8
|
+
LiveStatus,
|
|
6
9
|
MessageStats,
|
|
7
10
|
ModelDashboardStats,
|
|
8
11
|
OverviewStats,
|
|
9
12
|
ProviderDashboardStats,
|
|
13
|
+
ProviderWindowStats,
|
|
10
14
|
RequestDetails,
|
|
11
15
|
SessionSummary,
|
|
12
16
|
SessionTrace,
|
|
@@ -28,14 +32,33 @@ export class ApiError extends Error {
|
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
34
|
|
|
35
|
+
/** Prefer the server's `{ error }` body over a bare status line. */
|
|
36
|
+
async function readErrorMessage(res: Response, endpoint: string): Promise<string> {
|
|
37
|
+
try {
|
|
38
|
+
const body: unknown = await res.json();
|
|
39
|
+
if (typeof body === "object" && body !== null && "error" in body && typeof body.error === "string") {
|
|
40
|
+
return body.error;
|
|
41
|
+
}
|
|
42
|
+
} catch {
|
|
43
|
+
// Non-JSON error body: fall through to the status line.
|
|
44
|
+
}
|
|
45
|
+
return `HTTP error ${res.status} on ${endpoint}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
31
48
|
async function fetchJson<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
|
32
49
|
const res = await fetch(endpoint, options);
|
|
33
50
|
if (!res.ok) {
|
|
34
|
-
throw new ApiError(res.status, endpoint,
|
|
51
|
+
throw new ApiError(res.status, endpoint, await readErrorMessage(res, endpoint));
|
|
35
52
|
}
|
|
36
53
|
return res.json() as Promise<T>;
|
|
37
54
|
}
|
|
38
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Spend-incurring actions carry this custom header. It forces a CORS preflight
|
|
58
|
+
* the server never approves, so a cross-site page cannot trigger them.
|
|
59
|
+
*/
|
|
60
|
+
const ACTION_HEADERS = { "X-Omp-Stats-Action": "1" };
|
|
61
|
+
|
|
39
62
|
export async function getOverviewStats(range: TimeRange = "24h", signal?: AbortSignal): Promise<OverviewStats> {
|
|
40
63
|
return fetchJson<OverviewStats>(`${API_BASE}/stats/overview?range=${encodeURIComponent(range)}`, {
|
|
41
64
|
signal,
|
|
@@ -76,19 +99,42 @@ export async function getRequestDetails(id: number, signal?: AbortSignal): Promi
|
|
|
76
99
|
return fetchJson<RequestDetails>(`${API_BASE}/request/${id}`, { signal });
|
|
77
100
|
}
|
|
78
101
|
|
|
79
|
-
|
|
80
|
-
|
|
102
|
+
/** Ask the server to start a background sync; progress arrives on the `/api/events` stream. */
|
|
103
|
+
export async function requestSync(): Promise<LiveStatus> {
|
|
104
|
+
return fetchJson<LiveStatus>(`${API_BASE}/sync`, { method: "POST" });
|
|
81
105
|
}
|
|
82
106
|
|
|
83
|
-
export async function
|
|
107
|
+
export async function getFrustrationDashboardStats(
|
|
84
108
|
range: TimeRange = "24h",
|
|
85
109
|
signal?: AbortSignal,
|
|
86
|
-
): Promise<
|
|
87
|
-
return fetchJson<
|
|
110
|
+
): Promise<FrustrationDashboardStats> {
|
|
111
|
+
return fetchJson<FrustrationDashboardStats>(`${API_BASE}/stats/frustration?range=${encodeURIComponent(range)}`, {
|
|
112
|
+
signal,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Pre-run cost quote for judging every unjudged message in `range`. */
|
|
117
|
+
export async function getFrustrationEstimate(range: TimeRange, signal?: AbortSignal): Promise<FrustrationEstimate> {
|
|
118
|
+
return fetchJson<FrustrationEstimate>(`${API_BASE}/frustration/estimate?range=${encodeURIComponent(range)}`, {
|
|
88
119
|
signal,
|
|
89
120
|
});
|
|
90
121
|
}
|
|
91
122
|
|
|
123
|
+
/** Start the judge run over the unjudged messages in `range`. Spends money. */
|
|
124
|
+
export async function startFrustrationRun(range: TimeRange): Promise<FrustrationJobStatus> {
|
|
125
|
+
return fetchJson<FrustrationJobStatus>(`${API_BASE}/frustration/judge?range=${encodeURIComponent(range)}`, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
headers: ACTION_HEADERS,
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function cancelFrustrationRun(): Promise<FrustrationJobStatus> {
|
|
132
|
+
return fetchJson<FrustrationJobStatus>(`${API_BASE}/frustration/cancel`, {
|
|
133
|
+
method: "POST",
|
|
134
|
+
headers: ACTION_HEADERS,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
92
138
|
export async function getFolderStats(range: TimeRange = "24h", signal?: AbortSignal): Promise<FolderStats[]> {
|
|
93
139
|
return fetchJson<FolderStats[]>(`${API_BASE}/stats/folders?range=${encodeURIComponent(range)}`, { signal });
|
|
94
140
|
}
|
|
@@ -118,6 +164,18 @@ export async function getProviderDashboardStats(
|
|
|
118
164
|
signal,
|
|
119
165
|
});
|
|
120
166
|
}
|
|
167
|
+
|
|
168
|
+
/** Subscription-window insights (all providers) plus utilization series for `provider` only. */
|
|
169
|
+
export async function getProviderWindowStats(
|
|
170
|
+
range: TimeRange,
|
|
171
|
+
provider: string | null,
|
|
172
|
+
signal?: AbortSignal,
|
|
173
|
+
): Promise<ProviderWindowStats> {
|
|
174
|
+
const params = new URLSearchParams({ range });
|
|
175
|
+
if (provider !== null) params.set("provider", provider);
|
|
176
|
+
return fetchJson<ProviderWindowStats>(`${API_BASE}/stats/provider-windows?${params}`, { signal });
|
|
177
|
+
}
|
|
178
|
+
|
|
121
179
|
export async function getSessions(limit = 100, q?: string, signal?: AbortSignal): Promise<SessionSummary[]> {
|
|
122
180
|
const params = new URLSearchParams({ limit: String(limit) });
|
|
123
181
|
if (q) params.set("q", q);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { RefreshCw } from "lucide-react";
|
|
2
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
3
|
+
import { formatInteger } from "../data/formatters";
|
|
4
|
+
import { useLive } from "../data/live";
|
|
5
|
+
import { Dot } from "../ui";
|
|
6
|
+
|
|
7
|
+
/** Topbar ingest status; click to sync now. */
|
|
8
|
+
export function LiveChip() {
|
|
9
|
+
const { sync, indexingHours, connected, requestSync } = useLive();
|
|
10
|
+
const now = useNow(sync.phase === "idle" ? 15_000 : null);
|
|
11
|
+
|
|
12
|
+
let body: ReactNode;
|
|
13
|
+
let title: string;
|
|
14
|
+
if (!connected) {
|
|
15
|
+
body = (
|
|
16
|
+
<>
|
|
17
|
+
<Dot tone="warn" />
|
|
18
|
+
<span>Reconnecting</span>
|
|
19
|
+
</>
|
|
20
|
+
);
|
|
21
|
+
title = "Lost the live connection to the stats server; retrying";
|
|
22
|
+
} else if (sync.phase === "syncing") {
|
|
23
|
+
body = (
|
|
24
|
+
<>
|
|
25
|
+
<RefreshCw size={13} className="spin" />
|
|
26
|
+
<span>Syncing</span>
|
|
27
|
+
{sync.total > 0 && (
|
|
28
|
+
<span className="live-chip-count">
|
|
29
|
+
{formatInteger(sync.current)}/{formatInteger(sync.total)}
|
|
30
|
+
</span>
|
|
31
|
+
)}
|
|
32
|
+
</>
|
|
33
|
+
);
|
|
34
|
+
title = "Ingesting session files";
|
|
35
|
+
} else if (sync.phase === "error") {
|
|
36
|
+
body = (
|
|
37
|
+
<>
|
|
38
|
+
<Dot tone="bad" />
|
|
39
|
+
<span>Sync failed</span>
|
|
40
|
+
</>
|
|
41
|
+
);
|
|
42
|
+
title = `${sync.error ?? "Sync failed"} — click to retry`;
|
|
43
|
+
} else if (indexingHours > INDEXING_VISIBLE_HOURS) {
|
|
44
|
+
body = (
|
|
45
|
+
<>
|
|
46
|
+
<RefreshCw size={13} className="spin" />
|
|
47
|
+
<span>Indexing</span>
|
|
48
|
+
<span className="live-chip-count">{formatInteger(indexingHours)}h left</span>
|
|
49
|
+
</>
|
|
50
|
+
);
|
|
51
|
+
title = "Building hourly rollups; older ranges fill in as it goes";
|
|
52
|
+
} else {
|
|
53
|
+
body = (
|
|
54
|
+
<>
|
|
55
|
+
<Dot tone="live" pulse />
|
|
56
|
+
<span>Live</span>
|
|
57
|
+
{sync.lastSyncedAt !== null && <span className="live-chip-count">{ago(now - sync.lastSyncedAt)}</span>}
|
|
58
|
+
</>
|
|
59
|
+
);
|
|
60
|
+
title = "Click to sync session files now";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<button
|
|
65
|
+
type="button"
|
|
66
|
+
className="live-chip"
|
|
67
|
+
onClick={requestSync}
|
|
68
|
+
disabled={sync.phase === "syncing"}
|
|
69
|
+
title={title}
|
|
70
|
+
>
|
|
71
|
+
{body}
|
|
72
|
+
</button>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A handful of dirty hours is normal live churn; only a real backlog is worth showing. */
|
|
77
|
+
const INDEXING_VISIBLE_HOURS = 24;
|
|
78
|
+
|
|
79
|
+
function useNow(intervalMs: number | null): number {
|
|
80
|
+
const [now, setNow] = useState(Date.now);
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
if (intervalMs === null) return;
|
|
83
|
+
setNow(Date.now());
|
|
84
|
+
const timer = setInterval(() => setNow(Date.now()), intervalMs);
|
|
85
|
+
return () => clearInterval(timer);
|
|
86
|
+
}, [intervalMs]);
|
|
87
|
+
return now;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function ago(ms: number): string {
|
|
91
|
+
const s = Math.max(0, Math.round(ms / 1000));
|
|
92
|
+
if (s < 45) return "just now";
|
|
93
|
+
const m = Math.round(s / 60);
|
|
94
|
+
if (m < 60) return `${m}m ago`;
|
|
95
|
+
const h = Math.round(m / 60);
|
|
96
|
+
return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
|
|
97
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { Menu } from "lucide-react";
|
|
2
|
+
import { type ReactNode, useEffect, useState } from "react";
|
|
3
|
+
import { rangeMeta, TIME_RANGES } from "../data/range";
|
|
4
|
+
import { useLive } from "../data/live";
|
|
5
|
+
import type { TimeRange } from "../types";
|
|
6
|
+
import { Segmented } from "../ui";
|
|
7
|
+
import { LiveChip } from "./LiveChip";
|
|
8
|
+
import { type DashboardSection, NAV } from "./nav";
|
|
9
|
+
import { ThemeToggle } from "./ThemeToggle";
|
|
10
|
+
|
|
11
|
+
export interface ShellProps {
|
|
12
|
+
section: DashboardSection;
|
|
13
|
+
onSectionChange: (section: DashboardSection) => void;
|
|
14
|
+
range: TimeRange;
|
|
15
|
+
onRangeChange: (range: TimeRange) => void;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const RANGE_OPTIONS = TIME_RANGES.map((range, i) => ({
|
|
20
|
+
value: range,
|
|
21
|
+
label: rangeMeta(range).label,
|
|
22
|
+
title: `${rangeMeta(range).windowLabel} (${i + 1})`,
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Fixed glass topbar + naked sidebar framing one inset content panel. Owns
|
|
27
|
+
* the global keyboard shortcuts: `1`–`6` pick a range, `g` then a letter jumps
|
|
28
|
+
* to a page.
|
|
29
|
+
*/
|
|
30
|
+
export function Shell({ section, onSectionChange, range, onRangeChange, children }: ShellProps) {
|
|
31
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
32
|
+
const scrolled = useScrolled();
|
|
33
|
+
const { sync } = useLive();
|
|
34
|
+
useShortcuts(onSectionChange, onRangeChange);
|
|
35
|
+
|
|
36
|
+
const go = (next: DashboardSection) => {
|
|
37
|
+
onSectionChange(next);
|
|
38
|
+
setMenuOpen(false);
|
|
39
|
+
window.scrollTo({ top: 0 });
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const progress = sync.phase === "syncing" ? (sync.total > 0 ? sync.current / sync.total : null) : undefined;
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<>
|
|
46
|
+
<div className="shell-ambient" />
|
|
47
|
+
<header className="topbar" data-scrolled={scrolled}>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
className="btn topbar-menu"
|
|
51
|
+
data-variant="ghost"
|
|
52
|
+
data-icon="true"
|
|
53
|
+
onClick={() => setMenuOpen(true)}
|
|
54
|
+
aria-label="Open navigation"
|
|
55
|
+
>
|
|
56
|
+
<Menu size={16} />
|
|
57
|
+
</button>
|
|
58
|
+
<div className="topbar-brand">
|
|
59
|
+
<svg className="topbar-mark" viewBox="0 0 64 64" width="22" height="22" aria-hidden="true">
|
|
60
|
+
<defs>
|
|
61
|
+
<linearGradient id="omp-mark-grad" x1="0" y1="0" x2="1" y2="1">
|
|
62
|
+
<stop offset="0" stopColor="oklch(0.7 0.24 340)" />
|
|
63
|
+
<stop offset=".5" stopColor="oklch(0.62 0.21 295)" />
|
|
64
|
+
<stop offset="1" stopColor="oklch(0.81 0.14 200)" />
|
|
65
|
+
</linearGradient>
|
|
66
|
+
</defs>
|
|
67
|
+
<path fill="url(#omp-mark-grad)" d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
|
|
68
|
+
</svg>
|
|
69
|
+
<span>omp</span>
|
|
70
|
+
<span className="topbar-slash">/</span>
|
|
71
|
+
<span className="topbar-title">stats</span>
|
|
72
|
+
</div>
|
|
73
|
+
<div className="topbar-spacer" />
|
|
74
|
+
<div className="topbar-actions">
|
|
75
|
+
<span className="topbar-hide-narrow">
|
|
76
|
+
<LiveChip />
|
|
77
|
+
</span>
|
|
78
|
+
<Segmented options={RANGE_OPTIONS} value={range} onChange={onRangeChange} aria-label="Time range" />
|
|
79
|
+
<ThemeToggle />
|
|
80
|
+
</div>
|
|
81
|
+
{progress !== undefined && (
|
|
82
|
+
<div className="topbar-progress">
|
|
83
|
+
<div
|
|
84
|
+
className="topbar-progress-fill"
|
|
85
|
+
data-indeterminate={progress === null}
|
|
86
|
+
style={{ width: `${(progress ?? 0) * 100}%` }}
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
</header>
|
|
91
|
+
|
|
92
|
+
{menuOpen && <div className="drawer-scrim" onClick={() => setMenuOpen(false)} role="presentation" />}
|
|
93
|
+
<nav className="sidebar" data-open={menuOpen} aria-label="Pages">
|
|
94
|
+
{NAV.map(group => (
|
|
95
|
+
<div key={group.heading} className="nav-group">
|
|
96
|
+
<div className="nav-heading">{group.heading}</div>
|
|
97
|
+
{group.items.map(item => (
|
|
98
|
+
<button
|
|
99
|
+
key={item.id}
|
|
100
|
+
type="button"
|
|
101
|
+
className="nav-row"
|
|
102
|
+
data-active={item.id === section}
|
|
103
|
+
aria-current={item.id === section ? "page" : undefined}
|
|
104
|
+
onClick={() => go(item.id)}
|
|
105
|
+
>
|
|
106
|
+
<item.icon size={15} />
|
|
107
|
+
<span className="nav-row-label">{item.label}</span>
|
|
108
|
+
<kbd>G {item.hotkey.toUpperCase()}</kbd>
|
|
109
|
+
</button>
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
))}
|
|
113
|
+
<div className="sidebar-foot micro">
|
|
114
|
+
<span>
|
|
115
|
+
<span className="kbd">1</span>–<span className="kbd">6</span> range
|
|
116
|
+
</span>
|
|
117
|
+
<span>
|
|
118
|
+
<span className="kbd">G</span> then letter to jump
|
|
119
|
+
</span>
|
|
120
|
+
</div>
|
|
121
|
+
</nav>
|
|
122
|
+
|
|
123
|
+
<main className="shell-panel">
|
|
124
|
+
<div className="shell-content">{children}</div>
|
|
125
|
+
</main>
|
|
126
|
+
</>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function useScrolled(): boolean {
|
|
131
|
+
const [scrolled, setScrolled] = useState(false);
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
const onScroll = () => setScrolled(window.scrollY > 4);
|
|
134
|
+
onScroll();
|
|
135
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
136
|
+
return () => window.removeEventListener("scroll", onScroll);
|
|
137
|
+
}, []);
|
|
138
|
+
return scrolled;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function useShortcuts(onSection: (section: DashboardSection) => void, onRange: (range: TimeRange) => void): void {
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
let pendingG = 0;
|
|
144
|
+
const onKey = (e: KeyboardEvent) => {
|
|
145
|
+
if (e.metaKey || e.ctrlKey || e.altKey || e.defaultPrevented) return;
|
|
146
|
+
const target = e.target;
|
|
147
|
+
if (
|
|
148
|
+
target instanceof HTMLElement &&
|
|
149
|
+
(target.isContentEditable ||
|
|
150
|
+
target.tagName === "INPUT" ||
|
|
151
|
+
target.tagName === "TEXTAREA" ||
|
|
152
|
+
target.tagName === "SELECT")
|
|
153
|
+
) {
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (document.querySelector('[aria-modal="true"]')) return;
|
|
157
|
+
const key = e.key.toLowerCase();
|
|
158
|
+
if (pendingG && Date.now() - pendingG < 1200) {
|
|
159
|
+
pendingG = 0;
|
|
160
|
+
const item = NAV.flatMap(group => group.items).find(i => i.hotkey === key);
|
|
161
|
+
if (item) {
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
onSection(item.id);
|
|
164
|
+
window.scrollTo({ top: 0 });
|
|
165
|
+
}
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
if (key === "g") {
|
|
169
|
+
pendingG = Date.now();
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
const index = Number(key) - 1;
|
|
173
|
+
if (Number.isInteger(index) && index >= 0 && index < TIME_RANGES.length) {
|
|
174
|
+
e.preventDefault();
|
|
175
|
+
onRange(TIME_RANGES[index]);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
window.addEventListener("keydown", onKey);
|
|
179
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
180
|
+
}, [onSection, onRange]);
|
|
181
|
+
}
|
|
@@ -19,6 +19,7 @@ const PREFERENCE_LABEL: Record<ThemePreference, string> = {
|
|
|
19
19
|
dark: "Dark theme",
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
+
/** Cycles system → light → dark. */
|
|
22
23
|
export function ThemeToggle() {
|
|
23
24
|
const { preference, setPreference } = useThemePreference();
|
|
24
25
|
const Icon = PREFERENCE_ICON[preference];
|
|
@@ -26,12 +27,14 @@ export function ThemeToggle() {
|
|
|
26
27
|
return (
|
|
27
28
|
<button
|
|
28
29
|
type="button"
|
|
29
|
-
className="
|
|
30
|
+
className="btn"
|
|
31
|
+
data-variant="ghost"
|
|
32
|
+
data-icon="true"
|
|
30
33
|
onClick={() => setPreference(NEXT_PREFERENCE[preference])}
|
|
31
34
|
aria-label={`${PREFERENCE_LABEL[preference]} (click to switch)`}
|
|
32
35
|
title={`${PREFERENCE_LABEL[preference]} — click to switch`}
|
|
33
36
|
>
|
|
34
|
-
<Icon size={
|
|
37
|
+
<Icon size={15} />
|
|
35
38
|
</button>
|
|
36
39
|
);
|
|
37
40
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Activity,
|
|
3
|
+
CircleAlert,
|
|
4
|
+
Coins,
|
|
5
|
+
Cpu,
|
|
6
|
+
FolderGit2,
|
|
7
|
+
Frown,
|
|
8
|
+
LayoutGrid,
|
|
9
|
+
type LucideIcon,
|
|
10
|
+
PlugZap,
|
|
11
|
+
Sparkles,
|
|
12
|
+
SquareChartGantt,
|
|
13
|
+
Wrench,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
|
|
16
|
+
export type DashboardSection =
|
|
17
|
+
| "overview"
|
|
18
|
+
| "models"
|
|
19
|
+
| "providers"
|
|
20
|
+
| "costs"
|
|
21
|
+
| "requests"
|
|
22
|
+
| "errors"
|
|
23
|
+
| "traces"
|
|
24
|
+
| "tools"
|
|
25
|
+
| "frustration"
|
|
26
|
+
| "projects"
|
|
27
|
+
| "gain";
|
|
28
|
+
|
|
29
|
+
export interface NavItem {
|
|
30
|
+
id: DashboardSection;
|
|
31
|
+
label: string;
|
|
32
|
+
icon: LucideIcon;
|
|
33
|
+
/** Second key of the `g <key>` jump shortcut. */
|
|
34
|
+
hotkey: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface NavGroup {
|
|
38
|
+
heading: string;
|
|
39
|
+
items: readonly NavItem[];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Sidebar structure; the order here is the order on screen. */
|
|
43
|
+
export const NAV: readonly NavGroup[] = [
|
|
44
|
+
{
|
|
45
|
+
heading: "Usage",
|
|
46
|
+
items: [
|
|
47
|
+
{ id: "overview", label: "Overview", icon: LayoutGrid, hotkey: "o" },
|
|
48
|
+
{ id: "models", label: "Models", icon: Cpu, hotkey: "m" },
|
|
49
|
+
{ id: "providers", label: "Providers", icon: PlugZap, hotkey: "p" },
|
|
50
|
+
{ id: "costs", label: "Costs", icon: Coins, hotkey: "c" },
|
|
51
|
+
],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
heading: "Activity",
|
|
55
|
+
items: [
|
|
56
|
+
{ id: "requests", label: "Requests", icon: Activity, hotkey: "r" },
|
|
57
|
+
{ id: "errors", label: "Errors", icon: CircleAlert, hotkey: "e" },
|
|
58
|
+
{ id: "traces", label: "Traces", icon: SquareChartGantt, hotkey: "t" },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
heading: "Insights",
|
|
63
|
+
items: [
|
|
64
|
+
{ id: "tools", label: "Tools", icon: Wrench, hotkey: "l" },
|
|
65
|
+
{ id: "frustration", label: "Frustration", icon: Frown, hotkey: "f" },
|
|
66
|
+
{ id: "projects", label: "Projects", icon: FolderGit2, hotkey: "j" },
|
|
67
|
+
{ id: "gain", label: "Gain", icon: Sparkles, hotkey: "g" },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
export const NAV_ITEMS: readonly NavItem[] = NAV.flatMap(group => group.items);
|
|
73
|
+
|
|
74
|
+
export const SECTIONS: readonly DashboardSection[] = NAV_ITEMS.map(item => item.id);
|
|
75
|
+
|
|
76
|
+
export function navItem(section: DashboardSection): NavItem {
|
|
77
|
+
return NAV_ITEMS.find(item => item.id === section) ?? NAV_ITEMS[0];
|
|
78
|
+
}
|