@oh-my-pi/omp-stats 18.3.5 → 18.4.1
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 +22 -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 +143 -87
- 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
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { X } from "lucide-react";
|
|
2
|
+
import type React from "react";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
import { createPortal } from "react-dom";
|
|
5
|
+
import "./modal.css";
|
|
6
|
+
|
|
7
|
+
export interface ModalAction {
|
|
8
|
+
label: string;
|
|
9
|
+
onClick: () => void;
|
|
10
|
+
disabled?: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface ModalProps {
|
|
14
|
+
open: boolean;
|
|
15
|
+
title: React.ReactNode;
|
|
16
|
+
/** Called on Esc, overlay click, the close icon, and the cancel button. */
|
|
17
|
+
onClose: () => void;
|
|
18
|
+
/** Confirming action; receives focus whenever it is present and enabled. */
|
|
19
|
+
primaryAction?: ModalAction;
|
|
20
|
+
cancelLabel?: string;
|
|
21
|
+
children: React.ReactNode;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Centered dialog over a dimmed overlay, portalled to `document.body` so route
|
|
26
|
+
* containers cannot clip it. Focus lands on the primary action (or the cancel
|
|
27
|
+
* button when there is none) and returns to the opener on close.
|
|
28
|
+
*/
|
|
29
|
+
export function Modal({ open, title, onClose, primaryAction, cancelLabel = "Cancel", children }: ModalProps) {
|
|
30
|
+
const primaryRef = useRef<HTMLButtonElement>(null);
|
|
31
|
+
const cancelRef = useRef<HTMLButtonElement>(null);
|
|
32
|
+
const onCloseRef = useRef(onClose);
|
|
33
|
+
onCloseRef.current = onClose;
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (!open) return;
|
|
37
|
+
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
|
38
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
39
|
+
if (e.key === "Escape") {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
onCloseRef.current();
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
45
|
+
return () => {
|
|
46
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
47
|
+
opener?.focus();
|
|
48
|
+
};
|
|
49
|
+
}, [open]);
|
|
50
|
+
|
|
51
|
+
const hasPrimary = primaryAction !== undefined;
|
|
52
|
+
const primaryDisabled = primaryAction?.disabled ?? false;
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!open) return;
|
|
55
|
+
const target = hasPrimary && !primaryDisabled ? primaryRef.current : cancelRef.current;
|
|
56
|
+
target?.focus();
|
|
57
|
+
}, [open, hasPrimary, primaryDisabled]);
|
|
58
|
+
|
|
59
|
+
if (!open) return null;
|
|
60
|
+
|
|
61
|
+
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
62
|
+
if (e.target === e.currentTarget) onClose();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return createPortal(
|
|
66
|
+
<div className="modal-overlay" onClick={handleOverlayClick} role="presentation">
|
|
67
|
+
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
|
68
|
+
<div className="modal-header">
|
|
69
|
+
<h2 id="modal-title" className="modal-title">
|
|
70
|
+
{title}
|
|
71
|
+
</h2>
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
className="btn"
|
|
75
|
+
data-variant="ghost"
|
|
76
|
+
data-icon="true"
|
|
77
|
+
data-size="sm"
|
|
78
|
+
onClick={onClose}
|
|
79
|
+
aria-label="Close"
|
|
80
|
+
>
|
|
81
|
+
<X size={15} />
|
|
82
|
+
</button>
|
|
83
|
+
</div>
|
|
84
|
+
<div className="modal-body">{children}</div>
|
|
85
|
+
<div className="modal-footer">
|
|
86
|
+
<button ref={cancelRef} type="button" className="btn" onClick={onClose}>
|
|
87
|
+
{cancelLabel}
|
|
88
|
+
</button>
|
|
89
|
+
{primaryAction && (
|
|
90
|
+
<button
|
|
91
|
+
ref={primaryRef}
|
|
92
|
+
type="button"
|
|
93
|
+
className="btn"
|
|
94
|
+
data-variant="primary"
|
|
95
|
+
onClick={primaryAction.onClick}
|
|
96
|
+
disabled={primaryAction.disabled}
|
|
97
|
+
>
|
|
98
|
+
{primaryAction.label}
|
|
99
|
+
</button>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</div>,
|
|
104
|
+
document.body,
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -1,208 +1,183 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { useEffect, useRef, useState } from "react";
|
|
1
|
+
import { GitBranch } from "lucide-react";
|
|
3
2
|
import { getRequestDetails } from "../api";
|
|
4
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
formatCost,
|
|
5
|
+
formatDurationMs,
|
|
6
|
+
formatFolder,
|
|
7
|
+
formatInteger,
|
|
8
|
+
formatMessageCost,
|
|
9
|
+
formatRelativeTime,
|
|
10
|
+
formatTimestamp,
|
|
11
|
+
formatTokensPerSecond,
|
|
12
|
+
} from "../data/formatters";
|
|
13
|
+
import { useQuery } from "../data/query";
|
|
14
|
+
import { buildHash, parseHash } from "../data/useHashRoute";
|
|
15
|
+
import { type RequestStatus, requestStatus } from "../data/view-models";
|
|
5
16
|
import type { RequestDetails } from "../types";
|
|
17
|
+
import { Badge, type Tone } from "./Badge";
|
|
18
|
+
import { Drawer, KeyValues } from "./Drawer";
|
|
6
19
|
import { JsonBlock } from "./JsonBlock";
|
|
7
|
-
import { Skeleton } from "./
|
|
8
|
-
import
|
|
20
|
+
import { ErrorState, Skeleton } from "./States";
|
|
21
|
+
import "./request-drawer.css";
|
|
22
|
+
|
|
23
|
+
/** Label and tone for each request outcome; shared by the request tables. */
|
|
24
|
+
export const REQUEST_STATUS: Record<RequestStatus, { label: string; tone: Tone }> = {
|
|
25
|
+
ok: { label: "OK", tone: "ok" },
|
|
26
|
+
aborted: { label: "Aborted", tone: "warn" },
|
|
27
|
+
failed: { label: "Failed", tone: "bad" },
|
|
28
|
+
};
|
|
9
29
|
|
|
10
30
|
export interface RequestDrawerProps {
|
|
11
31
|
id: number | null;
|
|
12
32
|
onClose: () => void;
|
|
13
33
|
}
|
|
14
34
|
|
|
35
|
+
/** Full detail sheet for one model request: timing, tokens, cost, ids, error, and the raw session entry. */
|
|
15
36
|
export function RequestDrawer({ id, onClose }: RequestDrawerProps) {
|
|
16
|
-
const [
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
const closeButtonRef = useRef<HTMLButtonElement>(null);
|
|
21
|
-
|
|
22
|
-
useEffect(() => {
|
|
23
|
-
if (id === null) {
|
|
24
|
-
setDetails(null);
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
previousActiveElement.current = document.activeElement as HTMLElement | null;
|
|
29
|
-
setLoading(true);
|
|
30
|
-
setError(null);
|
|
31
|
-
setDetails(null);
|
|
32
|
-
|
|
33
|
-
const controller = new AbortController();
|
|
34
|
-
getRequestDetails(id, controller.signal)
|
|
35
|
-
.then(data => {
|
|
36
|
-
if (controller.signal.aborted) return;
|
|
37
|
-
setDetails(data);
|
|
38
|
-
// Focus the close button for accessibility
|
|
39
|
-
setTimeout(() => closeButtonRef.current?.focus(), 50);
|
|
40
|
-
})
|
|
41
|
-
.catch(err => {
|
|
42
|
-
if (controller.signal.aborted) return;
|
|
43
|
-
setError(err instanceof Error ? err : new Error(String(err)));
|
|
44
|
-
})
|
|
45
|
-
.finally(() => {
|
|
46
|
-
if (!controller.signal.aborted) setLoading(false);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
return () => controller.abort();
|
|
50
|
-
}, [id]);
|
|
51
|
-
|
|
52
|
-
useEffect(() => {
|
|
53
|
-
if (id === null) return;
|
|
37
|
+
const query = useQuery(["request", id], () => getRequestDetails(id ?? 0), { enabled: id !== null });
|
|
38
|
+
// Never show the previous request's payload under a new id.
|
|
39
|
+
const details = query.stale ? null : query.data;
|
|
40
|
+
const status = details ? REQUEST_STATUS[requestStatus(details)] : null;
|
|
54
41
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
42
|
+
return (
|
|
43
|
+
<Drawer
|
|
44
|
+
open={id !== null}
|
|
45
|
+
onClose={onClose}
|
|
46
|
+
width={620}
|
|
47
|
+
title={details ? <span className="mono">{details.model}</span> : "Request"}
|
|
48
|
+
subtitle={
|
|
49
|
+
details ? (
|
|
50
|
+
<>
|
|
51
|
+
{details.provider} ·{" "}
|
|
52
|
+
<span title={formatTimestamp(details.timestamp)}>{formatRelativeTime(details.timestamp)}</span>
|
|
53
|
+
</>
|
|
54
|
+
) : id !== null ? (
|
|
55
|
+
<span className="mono">#{id}</span>
|
|
56
|
+
) : undefined
|
|
58
57
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
58
|
+
actions={
|
|
59
|
+
details && status ? (
|
|
60
|
+
<div className="row" style={{ gap: 6 }}>
|
|
61
|
+
<a
|
|
62
|
+
className="btn"
|
|
63
|
+
data-size="sm"
|
|
64
|
+
data-variant="ghost"
|
|
65
|
+
href={buildHash({
|
|
66
|
+
...parseHash(window.location.hash),
|
|
67
|
+
section: "traces",
|
|
68
|
+
session: details.sessionFile,
|
|
69
|
+
})}
|
|
70
|
+
onClick={onClose}
|
|
71
|
+
title="Open this request's session in the trace view"
|
|
72
|
+
>
|
|
73
|
+
<GitBranch size={13} /> Trace
|
|
74
|
+
</a>
|
|
75
|
+
<Badge tone={status.tone}>{status.label}</Badge>
|
|
76
|
+
</div>
|
|
77
|
+
) : undefined
|
|
66
78
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
79
|
+
>
|
|
80
|
+
{details ? (
|
|
81
|
+
<RequestDetailsBody details={details} aborted={requestStatus(details) === "aborted"} />
|
|
82
|
+
) : query.error ? (
|
|
83
|
+
<ErrorState error={query.error} onRetry={query.refetch} />
|
|
84
|
+
) : (
|
|
85
|
+
<div className="stack" style={{ gap: 12 }}>
|
|
86
|
+
<Skeleton height={56} />
|
|
87
|
+
<Skeleton height={120} />
|
|
88
|
+
<Skeleton height={120} />
|
|
89
|
+
<Skeleton height={220} />
|
|
90
|
+
</div>
|
|
91
|
+
)}
|
|
92
|
+
</Drawer>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
71
95
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
96
|
+
function RequestDetailsBody({ details, aborted }: { details: RequestDetails; aborted: boolean }) {
|
|
97
|
+
const { usage } = details;
|
|
98
|
+
const throughput =
|
|
99
|
+
details.duration !== null && details.duration > 0 && usage.output > 0
|
|
100
|
+
? (usage.output * 1000) / details.duration
|
|
101
|
+
: null;
|
|
102
|
+
// The stats row as ingested, without the session payload shown separately below.
|
|
103
|
+
const { messages, output, ...row } = details;
|
|
77
104
|
|
|
78
105
|
return (
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
{
|
|
82
|
-
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
106
|
+
<>
|
|
107
|
+
{details.errorMessage && (
|
|
108
|
+
<section className="request-drawer-error" data-tone={aborted ? "warn" : "bad"} role="note">
|
|
109
|
+
<div className="request-drawer-error-label">{aborted ? "Aborted" : "Error"}</div>
|
|
110
|
+
<pre className="request-drawer-error-text">{details.errorMessage}</pre>
|
|
111
|
+
</section>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
<section>
|
|
115
|
+
<div className="section-label">Timing</div>
|
|
116
|
+
<KeyValues
|
|
117
|
+
items={[
|
|
118
|
+
{ key: "at", label: "Started", value: formatTimestamp(details.timestamp) },
|
|
119
|
+
{ key: "duration", label: "Duration", value: formatDurationMs(details.duration) },
|
|
120
|
+
{ key: "ttft", label: "Time to first token", value: formatDurationMs(details.ttft) },
|
|
121
|
+
{ key: "tps", label: "Output tokens/s", value: formatTokensPerSecond(throughput) },
|
|
122
|
+
]}
|
|
123
|
+
/>
|
|
124
|
+
</section>
|
|
125
|
+
|
|
126
|
+
<section>
|
|
127
|
+
<div className="section-label">Tokens</div>
|
|
128
|
+
<KeyValues
|
|
129
|
+
items={[
|
|
130
|
+
{ key: "input", label: "Uncached input", value: formatInteger(usage.input) },
|
|
131
|
+
{ key: "cacheRead", label: "Cache read", value: formatInteger(usage.cacheRead) },
|
|
132
|
+
{ key: "cacheWrite", label: "Cache write", value: formatInteger(usage.cacheWrite) },
|
|
133
|
+
{ key: "output", label: "Output", value: formatInteger(usage.output) },
|
|
134
|
+
{ key: "total", label: "Total", value: formatInteger(usage.totalTokens) },
|
|
135
|
+
{
|
|
136
|
+
key: "premium",
|
|
137
|
+
label: "Premium requests",
|
|
138
|
+
value: formatInteger(Math.round((usage.premiumRequests ?? 0) * 100) / 100),
|
|
139
|
+
},
|
|
140
|
+
]}
|
|
141
|
+
/>
|
|
142
|
+
</section>
|
|
143
|
+
|
|
144
|
+
<section>
|
|
145
|
+
<div className="section-label">API-equivalent cost</div>
|
|
146
|
+
<KeyValues
|
|
147
|
+
items={[
|
|
148
|
+
{ key: "total", label: "Total", value: formatMessageCost(details, 4) },
|
|
149
|
+
{ key: "input", label: "Input", value: formatCost(usage.cost.input, 4) },
|
|
150
|
+
{ key: "cacheRead", label: "Cache read", value: formatCost(usage.cost.cacheRead, 4) },
|
|
151
|
+
{ key: "cacheWrite", label: "Cache write", value: formatCost(usage.cost.cacheWrite, 4) },
|
|
152
|
+
{ key: "output", label: "Output", value: formatCost(usage.cost.output, 4) },
|
|
153
|
+
]}
|
|
154
|
+
/>
|
|
155
|
+
</section>
|
|
156
|
+
|
|
157
|
+
<section>
|
|
158
|
+
<div className="section-label">Identity</div>
|
|
159
|
+
<KeyValues
|
|
160
|
+
items={[
|
|
161
|
+
{ key: "id", label: "Request id", value: details.id ?? "–" },
|
|
162
|
+
{ key: "entry", label: "Entry id", value: details.entryId },
|
|
163
|
+
{ key: "stop", label: "Stop reason", value: details.stopReason },
|
|
164
|
+
{ key: "api", label: "API", value: details.api },
|
|
165
|
+
{
|
|
166
|
+
key: "project",
|
|
167
|
+
label: "Project",
|
|
168
|
+
value: <span title={details.folder}>{formatFolder(details.folder)}</span>,
|
|
169
|
+
},
|
|
170
|
+
]}
|
|
171
|
+
/>
|
|
172
|
+
<div className="request-drawer-file">
|
|
173
|
+
<span className="kv-key">Session file</span>
|
|
174
|
+
<span className="kv-value">{details.sessionFile}</span>
|
|
96
175
|
</div>
|
|
176
|
+
</section>
|
|
97
177
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
<Skeleton variant="rect" width="100%" height={80} className="mb-4" />
|
|
103
|
-
<Skeleton variant="rect" width="100%" height={120} className="mb-4" />
|
|
104
|
-
<Skeleton variant="rect" width="100%" height={200} />
|
|
105
|
-
</div>
|
|
106
|
-
)}
|
|
107
|
-
|
|
108
|
-
{error && (
|
|
109
|
-
<div className="stats-drawer-error">
|
|
110
|
-
<p className="stats-drawer-error-title">Failed to load request details</p>
|
|
111
|
-
<p className="stats-drawer-error-message">{error.message}</p>
|
|
112
|
-
</div>
|
|
113
|
-
)}
|
|
114
|
-
|
|
115
|
-
{details && (
|
|
116
|
-
<div className="stats-drawer-content">
|
|
117
|
-
{/* Status Card */}
|
|
118
|
-
<div className="stats-drawer-status-card">
|
|
119
|
-
<div className="stats-drawer-status-row">
|
|
120
|
-
<div>
|
|
121
|
-
<div className="stats-drawer-model">{details.model}</div>
|
|
122
|
-
<div className="stats-drawer-provider">{details.provider}</div>
|
|
123
|
-
</div>
|
|
124
|
-
<StatusPill variant={details.errorMessage ? "danger" : "success"}>
|
|
125
|
-
{details.errorMessage ? "Error" : "Success"}
|
|
126
|
-
</StatusPill>
|
|
127
|
-
</div>
|
|
128
|
-
{details.errorMessage && (
|
|
129
|
-
<div className="stats-drawer-error-block">
|
|
130
|
-
<div className="stats-drawer-error-label">Error Message</div>
|
|
131
|
-
<div className="stats-drawer-error-text">{details.errorMessage}</div>
|
|
132
|
-
</div>
|
|
133
|
-
)}
|
|
134
|
-
</div>
|
|
135
|
-
|
|
136
|
-
{/* Metrics Grid */}
|
|
137
|
-
<div className="stats-drawer-metrics-grid">
|
|
138
|
-
<div className="stats-drawer-metric-card">
|
|
139
|
-
<div className="stats-drawer-metric-label">
|
|
140
|
-
<Coins size={14} className="stats-drawer-metric-icon" />
|
|
141
|
-
API-equivalent estimate
|
|
142
|
-
</div>
|
|
143
|
-
<div className="stats-drawer-metric-value">{formatMessageCost(details, 4)}</div>
|
|
144
|
-
</div>
|
|
145
|
-
|
|
146
|
-
<div className="stats-drawer-metric-card">
|
|
147
|
-
<div className="stats-drawer-metric-label">
|
|
148
|
-
<Star size={14} className="stats-drawer-metric-icon" />
|
|
149
|
-
Premium
|
|
150
|
-
</div>
|
|
151
|
-
<div className="stats-drawer-metric-value">
|
|
152
|
-
{formatInteger(details.usage.premiumRequests ?? 0)}
|
|
153
|
-
</div>
|
|
154
|
-
</div>
|
|
155
|
-
|
|
156
|
-
<div className="stats-drawer-metric-card">
|
|
157
|
-
<div className="stats-drawer-metric-label">
|
|
158
|
-
<Hash size={14} className="stats-drawer-metric-icon" />
|
|
159
|
-
Total Tokens
|
|
160
|
-
</div>
|
|
161
|
-
<div className="stats-drawer-metric-value">{formatInteger(details.usage.totalTokens)}</div>
|
|
162
|
-
<div className="stats-drawer-metric-sub">
|
|
163
|
-
{formatInteger(details.usage.input)} in · {formatInteger(details.usage.output)} out
|
|
164
|
-
</div>
|
|
165
|
-
</div>
|
|
166
|
-
|
|
167
|
-
<div className="stats-drawer-metric-card">
|
|
168
|
-
<div className="stats-drawer-metric-label">
|
|
169
|
-
<Clock size={14} className="stats-drawer-metric-icon" />
|
|
170
|
-
Duration
|
|
171
|
-
</div>
|
|
172
|
-
<div className="stats-drawer-metric-value">{formatDurationMs(details.duration)}</div>
|
|
173
|
-
</div>
|
|
174
|
-
|
|
175
|
-
<div className="stats-drawer-metric-card">
|
|
176
|
-
<div className="stats-drawer-metric-label">
|
|
177
|
-
<Zap size={14} className="stats-drawer-metric-icon" />
|
|
178
|
-
TTFT
|
|
179
|
-
</div>
|
|
180
|
-
<div className="stats-drawer-metric-value">{formatDurationMs(details.ttft)}</div>
|
|
181
|
-
</div>
|
|
182
|
-
|
|
183
|
-
{details.duration && details.usage.output > 0 && (
|
|
184
|
-
<div className="stats-drawer-metric-card">
|
|
185
|
-
<div className="stats-drawer-metric-label">
|
|
186
|
-
<Gauge size={14} className="stats-drawer-metric-icon" />
|
|
187
|
-
Throughput
|
|
188
|
-
</div>
|
|
189
|
-
<div className="stats-drawer-metric-value">
|
|
190
|
-
{((details.usage.output * 1000) / details.duration).toFixed(1)}
|
|
191
|
-
</div>
|
|
192
|
-
<div className="stats-drawer-metric-sub">tokens/second</div>
|
|
193
|
-
</div>
|
|
194
|
-
)}
|
|
195
|
-
</div>
|
|
196
|
-
|
|
197
|
-
{/* JSON blocks */}
|
|
198
|
-
<div className="stats-drawer-json-blocks">
|
|
199
|
-
<JsonBlock data={details.output} title="Output Payload" initialCollapsed={false} />
|
|
200
|
-
<JsonBlock data={details} title="Raw Request Metadata" initialCollapsed={true} />
|
|
201
|
-
</div>
|
|
202
|
-
</div>
|
|
203
|
-
)}
|
|
204
|
-
</div>
|
|
205
|
-
</div>
|
|
206
|
-
</div>
|
|
178
|
+
<JsonBlock data={output} title="Output message" />
|
|
179
|
+
<JsonBlock data={messages} title="Session entry" initialCollapsed />
|
|
180
|
+
<JsonBlock data={row} title="Stats row" initialCollapsed />
|
|
181
|
+
</>
|
|
207
182
|
);
|
|
208
183
|
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Search } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
export interface SearchInputProps {
|
|
4
|
+
value: string;
|
|
5
|
+
onChange: (value: string) => void;
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
width?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Text filter with a leading search glyph. */
|
|
11
|
+
export function SearchInput({ value, onChange, placeholder = "Filter…", width }: SearchInputProps) {
|
|
12
|
+
return (
|
|
13
|
+
<label className="search">
|
|
14
|
+
<Search size={14} />
|
|
15
|
+
<input
|
|
16
|
+
className="input"
|
|
17
|
+
type="search"
|
|
18
|
+
value={value}
|
|
19
|
+
placeholder={placeholder}
|
|
20
|
+
onChange={e => onChange(e.target.value)}
|
|
21
|
+
style={width !== undefined ? { width } : undefined}
|
|
22
|
+
/>
|
|
23
|
+
</label>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface SegmentedOption<T extends string> {
|
|
4
|
+
value: T;
|
|
5
|
+
label: ReactNode;
|
|
6
|
+
title?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SegmentedProps<T extends string> {
|
|
10
|
+
options: readonly SegmentedOption<T>[];
|
|
11
|
+
value: T;
|
|
12
|
+
onChange: (value: T) => void;
|
|
13
|
+
size?: "md" | "sm";
|
|
14
|
+
"aria-label"?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Pill toggle with a measured sliding thumb. */
|
|
18
|
+
export function Segmented<T extends string>({
|
|
19
|
+
options,
|
|
20
|
+
value,
|
|
21
|
+
onChange,
|
|
22
|
+
size = "md",
|
|
23
|
+
"aria-label": ariaLabel,
|
|
24
|
+
}: SegmentedProps<T>) {
|
|
25
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
26
|
+
const [thumb, setThumb] = useState<{ left: number; width: number } | null>(null);
|
|
27
|
+
|
|
28
|
+
useLayoutEffect(() => {
|
|
29
|
+
const root = rootRef.current;
|
|
30
|
+
const active = root?.querySelector<HTMLElement>('[data-active="true"]');
|
|
31
|
+
if (!root || !active) {
|
|
32
|
+
setThumb(null);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const measure = () => setThumb({ left: active.offsetLeft, width: active.offsetWidth });
|
|
36
|
+
measure();
|
|
37
|
+
const observer = new ResizeObserver(measure);
|
|
38
|
+
observer.observe(root);
|
|
39
|
+
return () => observer.disconnect();
|
|
40
|
+
}, [value, options]);
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<div ref={rootRef} className="segmented" data-size={size} role="radiogroup" aria-label={ariaLabel}>
|
|
44
|
+
{thumb && <span className="segmented-thumb" style={{ left: thumb.left, width: thumb.width }} />}
|
|
45
|
+
{options.map(option => (
|
|
46
|
+
<button
|
|
47
|
+
key={option.value}
|
|
48
|
+
type="button"
|
|
49
|
+
role="radio"
|
|
50
|
+
aria-checked={option.value === value}
|
|
51
|
+
className="segmented-option"
|
|
52
|
+
data-active={option.value === value}
|
|
53
|
+
title={option.title}
|
|
54
|
+
onClick={() => onChange(option.value)}
|
|
55
|
+
>
|
|
56
|
+
{option.label}
|
|
57
|
+
</button>
|
|
58
|
+
))}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Sparkline } from "../charts/Sparkline";
|
|
3
|
+
|
|
4
|
+
export interface StatDelta {
|
|
5
|
+
/** Relative change vs the previous period (0.12 = +12%). `null` when there is no baseline. */
|
|
6
|
+
change: number | null;
|
|
7
|
+
/** Which direction is good news. Default `"up"`. */
|
|
8
|
+
good?: "up" | "down";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface StatProps {
|
|
12
|
+
label: ReactNode;
|
|
13
|
+
value: ReactNode;
|
|
14
|
+
/** Small print under the value. */
|
|
15
|
+
hint?: ReactNode;
|
|
16
|
+
delta?: StatDelta;
|
|
17
|
+
/** Trend under the value. */
|
|
18
|
+
spark?: readonly number[];
|
|
19
|
+
sparkColor?: string;
|
|
20
|
+
size?: "md" | "sm";
|
|
21
|
+
/** Title tooltip explaining the metric. */
|
|
22
|
+
title?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* One metric tile. The value flashes briefly when it changes after mount, so
|
|
27
|
+
* live updates are noticeable without being loud.
|
|
28
|
+
*/
|
|
29
|
+
export function Stat({ label, value, hint, delta, spark, sparkColor, size = "md", title }: StatProps) {
|
|
30
|
+
const flash = useChangeFlash(typeof value === "string" || typeof value === "number" ? value : null);
|
|
31
|
+
return (
|
|
32
|
+
<div className="stat" data-size={size} title={title}>
|
|
33
|
+
<div className="stat-label">{label}</div>
|
|
34
|
+
<div className="stat-value" data-flash={flash}>
|
|
35
|
+
{value}
|
|
36
|
+
</div>
|
|
37
|
+
{(hint !== undefined || delta) && (
|
|
38
|
+
<div className="stat-foot">
|
|
39
|
+
{delta && <Delta {...delta} />}
|
|
40
|
+
{hint !== undefined && <span className="truncate">{hint}</span>}
|
|
41
|
+
</div>
|
|
42
|
+
)}
|
|
43
|
+
{spark && spark.length > 1 && (
|
|
44
|
+
<div className="stat-spark">
|
|
45
|
+
<Sparkline values={spark} color={sparkColor} />
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface StatGridProps {
|
|
53
|
+
/** Minimum tile width before wrapping. Default 170px. */
|
|
54
|
+
min?: number;
|
|
55
|
+
children: ReactNode;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Tiles sharing one card, separated by hairlines. */
|
|
59
|
+
export function StatGrid({ min, children }: StatGridProps) {
|
|
60
|
+
return (
|
|
61
|
+
<div
|
|
62
|
+
className="stat-grid rise"
|
|
63
|
+
style={min !== undefined ? ({ "--stat-min": `${min}px` } as CSSProperties) : undefined}
|
|
64
|
+
>
|
|
65
|
+
{children}
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Signed percentage change pill, green when the move is good news. */
|
|
71
|
+
export function Delta({ change, good = "up" }: StatDelta) {
|
|
72
|
+
if (change === null || !Number.isFinite(change)) return <span className="delta">new</span>;
|
|
73
|
+
const rounded = Math.abs(change) < 0.0005 ? 0 : change;
|
|
74
|
+
const tone = rounded === 0 ? undefined : rounded > 0 === (good === "up") ? "good" : "bad";
|
|
75
|
+
const sign = rounded > 0 ? "+" : rounded < 0 ? "−" : "±";
|
|
76
|
+
const pct = Math.abs(rounded) * 100;
|
|
77
|
+
return (
|
|
78
|
+
<span className="delta" data-tone={tone}>
|
|
79
|
+
{sign}
|
|
80
|
+
{pct >= 100 ? pct.toFixed(0) : pct.toFixed(1)}%
|
|
81
|
+
</span>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Relative change from `previous` to `current`; `null` when there is no baseline. */
|
|
86
|
+
export function relativeChange(current: number, previous: number): number | null {
|
|
87
|
+
if (previous === 0) return current === 0 ? 0 : null;
|
|
88
|
+
return (current - previous) / previous;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function useChangeFlash(value: string | number | null): boolean {
|
|
92
|
+
const previous = useRef(value);
|
|
93
|
+
const [flash, setFlash] = useState(false);
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (previous.current === value) return;
|
|
96
|
+
const hadValue = previous.current !== null;
|
|
97
|
+
previous.current = value;
|
|
98
|
+
if (!hadValue) return;
|
|
99
|
+
setFlash(true);
|
|
100
|
+
const timer = setTimeout(() => setFlash(false), 900);
|
|
101
|
+
return () => clearTimeout(timer);
|
|
102
|
+
}, [value]);
|
|
103
|
+
return flash;
|
|
104
|
+
}
|