@oh-my-pi/omp-stats 16.0.3 → 16.0.5
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 +15 -0
- package/build.ts +11 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +11 -0
- package/dist/client/index.js +108 -108
- package/dist/client/styles.css +1070 -631
- package/dist/types/client/api.d.ts +19 -10
- package/dist/types/client/app/AppLayout.d.ts +16 -0
- package/dist/types/client/app/NavRail.d.ts +7 -0
- package/dist/types/client/app/RangeControl.d.ts +7 -0
- package/dist/types/client/app/SyncButton.d.ts +14 -0
- package/dist/types/client/app/ThemeToggle.d.ts +1 -0
- package/dist/types/client/app/TopBar.d.ts +15 -0
- package/dist/types/client/app/routes.d.ts +12 -0
- package/dist/types/client/components/chart-shared.d.ts +26 -40
- package/dist/types/client/components/models-table-shared.d.ts +20 -40
- package/dist/types/client/data/charts.d.ts +1 -0
- package/dist/types/client/data/formatters.d.ts +7 -0
- package/dist/types/client/data/useHashRoute.d.ts +8 -0
- package/dist/types/client/data/useResource.d.ts +13 -0
- package/dist/types/client/data/view-models.d.ts +37 -0
- package/dist/types/client/index.d.ts +1 -0
- package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
- package/dist/types/client/routes/CostsRoute.d.ts +7 -0
- package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
- package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
- package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
- package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
- package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
- package/dist/types/client/routes/index.d.ts +7 -0
- package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
- package/dist/types/client/ui/DataTable.d.ts +17 -0
- package/dist/types/client/ui/EmptyState.d.ts +7 -0
- package/dist/types/client/ui/ErrorState.d.ts +6 -0
- package/dist/types/client/ui/JsonBlock.d.ts +7 -0
- package/dist/types/client/ui/MetricCluster.d.ts +5 -0
- package/dist/types/client/ui/Panel.d.ts +7 -0
- package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
- package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
- package/dist/types/client/ui/Skeleton.d.ts +8 -0
- package/dist/types/client/ui/StatusPill.d.ts +7 -0
- package/dist/types/client/ui/index.d.ts +11 -0
- package/dist/types/client/useSystemTheme.d.ts +9 -0
- package/package.json +4 -4
- package/src/aggregator.ts +4 -3
- package/src/client/App.tsx +89 -207
- package/src/client/api.ts +55 -37
- package/src/client/app/AppLayout.tsx +93 -0
- package/src/client/app/NavRail.tsx +44 -0
- package/src/client/app/RangeControl.tsx +39 -0
- package/src/client/app/SyncButton.tsx +75 -0
- package/src/client/app/ThemeToggle.tsx +37 -0
- package/src/client/app/TopBar.tsx +73 -0
- package/src/client/app/routes.ts +50 -0
- package/src/client/components/chart-shared.tsx +28 -91
- package/src/client/components/models-table-shared.tsx +9 -29
- package/src/client/components/range-meta.ts +3 -2
- package/src/client/data/charts.ts +14 -0
- package/src/client/data/formatters.ts +38 -0
- package/src/client/data/useHashRoute.ts +85 -0
- package/src/client/data/useResource.ts +154 -0
- package/src/client/data/view-models.ts +178 -0
- package/src/client/index.tsx +4 -0
- package/src/client/routes/BehaviorRoute.tsx +623 -0
- package/src/client/routes/CostsRoute.tsx +234 -0
- package/src/client/routes/ErrorsRoute.tsx +118 -0
- package/src/client/routes/ModelsRoute.tsx +430 -0
- package/src/client/routes/OverviewRoute.tsx +332 -0
- package/src/client/routes/ProjectsRoute.tsx +163 -0
- package/src/client/routes/RequestsRoute.tsx +123 -0
- package/src/client/routes/index.ts +7 -0
- package/src/client/styles.css +1242 -225
- package/src/client/ui/AsyncBoundary.tsx +54 -0
- package/src/client/ui/DataTable.tsx +122 -0
- package/src/client/ui/EmptyState.tsx +16 -0
- package/src/client/ui/ErrorState.tsx +25 -0
- package/src/client/ui/JsonBlock.tsx +75 -0
- package/src/client/ui/MetricCluster.tsx +67 -0
- package/src/client/ui/Panel.tsx +24 -0
- package/src/client/ui/RequestDrawer.tsx +208 -0
- package/src/client/ui/SegmentedControl.tsx +36 -0
- package/src/client/ui/Skeleton.tsx +17 -0
- package/src/client/ui/StatusPill.tsx +15 -0
- package/src/client/ui/index.ts +11 -0
- package/src/client/useSystemTheme.ts +73 -17
- package/dist/types/client/components/BehaviorChart.d.ts +0 -6
- package/dist/types/client/components/BehaviorModelsTable.d.ts +0 -7
- package/dist/types/client/components/BehaviorSummary.d.ts +0 -7
- package/dist/types/client/components/ChartsContainer.d.ts +0 -7
- package/dist/types/client/components/CostChart.d.ts +0 -6
- package/dist/types/client/components/CostSummary.d.ts +0 -6
- package/dist/types/client/components/Header.d.ts +0 -12
- package/dist/types/client/components/ModelsTable.d.ts +0 -8
- package/dist/types/client/components/RequestDetail.d.ts +0 -6
- package/dist/types/client/components/RequestList.d.ts +0 -8
- package/dist/types/client/components/StatsGrid.d.ts +0 -6
- package/src/client/components/BehaviorChart.tsx +0 -189
- package/src/client/components/BehaviorModelsTable.tsx +0 -342
- package/src/client/components/BehaviorSummary.tsx +0 -95
- package/src/client/components/ChartsContainer.tsx +0 -221
- package/src/client/components/CostChart.tsx +0 -171
- package/src/client/components/CostSummary.tsx +0 -53
- package/src/client/components/Header.tsx +0 -72
- package/src/client/components/ModelsTable.tsx +0 -265
- package/src/client/components/RequestDetail.tsx +0 -172
- package/src/client/components/RequestList.tsx +0 -73
- package/src/client/components/StatsGrid.tsx +0 -135
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { EmptyState } from "./EmptyState";
|
|
3
|
+
import { ErrorState } from "./ErrorState";
|
|
4
|
+
import { Skeleton } from "./Skeleton";
|
|
5
|
+
|
|
6
|
+
export interface AsyncBoundaryProps {
|
|
7
|
+
loading: boolean;
|
|
8
|
+
error: Error | null;
|
|
9
|
+
data: unknown | null;
|
|
10
|
+
empty?: boolean;
|
|
11
|
+
emptyText?: string;
|
|
12
|
+
fallback?: React.ReactNode;
|
|
13
|
+
onRetry?: () => void;
|
|
14
|
+
children: React.ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function AsyncBoundary({
|
|
18
|
+
loading,
|
|
19
|
+
error,
|
|
20
|
+
data,
|
|
21
|
+
empty = false,
|
|
22
|
+
emptyText = "No data available",
|
|
23
|
+
fallback,
|
|
24
|
+
onRetry,
|
|
25
|
+
children,
|
|
26
|
+
}: AsyncBoundaryProps) {
|
|
27
|
+
// If there's an error and no stale data, render ErrorState
|
|
28
|
+
if (error && data === null) {
|
|
29
|
+
return <ErrorState error={error} onRetry={onRetry} />;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// If it's loading and there is no stale data, render Loading state / Skeleton
|
|
33
|
+
if (loading && data === null) {
|
|
34
|
+
if (fallback) {
|
|
35
|
+
return <>{fallback}</>;
|
|
36
|
+
}
|
|
37
|
+
return (
|
|
38
|
+
<div className="stats-boundary-skeleton">
|
|
39
|
+
<Skeleton variant="text" width="60%" height={24} className="mb-4" />
|
|
40
|
+
<Skeleton variant="rect" width="100%" height={160} className="mb-4" />
|
|
41
|
+
<Skeleton variant="text" width="80%" height={20} className="mb-2" />
|
|
42
|
+
<Skeleton variant="text" width="40%" height={20} />
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// If there is data but it's empty, render EmptyState
|
|
48
|
+
if (!loading && (empty || data === null)) {
|
|
49
|
+
return <EmptyState message={emptyText} />;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Render children (stale data is kept visible even if loading is true in background)
|
|
53
|
+
return <>{children}</>;
|
|
54
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
|
|
3
|
+
export interface DataTableColumn<T> {
|
|
4
|
+
key: string;
|
|
5
|
+
header: React.ReactNode;
|
|
6
|
+
render?: (item: T) => React.ReactNode;
|
|
7
|
+
className?: string;
|
|
8
|
+
numeric?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface DataTableProps<T> {
|
|
12
|
+
columns: DataTableColumn<T>[];
|
|
13
|
+
data: T[];
|
|
14
|
+
keyExtractor: (item: T) => string | number;
|
|
15
|
+
onRowClick?: (item: T) => void;
|
|
16
|
+
renderMobileCard?: (item: T, onClick?: () => void) => React.ReactNode;
|
|
17
|
+
emptyText?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function DataTable<T>({
|
|
21
|
+
columns,
|
|
22
|
+
data,
|
|
23
|
+
keyExtractor,
|
|
24
|
+
onRowClick,
|
|
25
|
+
renderMobileCard,
|
|
26
|
+
emptyText = "No data available",
|
|
27
|
+
}: DataTableProps<T>) {
|
|
28
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTableRowElement>, item: T) => {
|
|
29
|
+
if (onRowClick && (e.key === "Enter" || e.key === " ")) {
|
|
30
|
+
e.preventDefault();
|
|
31
|
+
onRowClick(item);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
if (data.length === 0) {
|
|
36
|
+
return <div className="stats-table-empty">{emptyText}</div>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="stats-table-wrapper">
|
|
41
|
+
{/* Mobile layout */}
|
|
42
|
+
{renderMobileCard && (
|
|
43
|
+
<div className="stats-table-mobile-only">
|
|
44
|
+
<div className="stats-table-mobile-list">
|
|
45
|
+
{data.map(item => {
|
|
46
|
+
const key = String(keyExtractor(item));
|
|
47
|
+
const onClick = onRowClick ? () => onRowClick(item) : undefined;
|
|
48
|
+
return (
|
|
49
|
+
<div key={key} className="stats-table-mobile-card-wrapper">
|
|
50
|
+
{renderMobileCard(item, onClick)}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
})}
|
|
54
|
+
</div>
|
|
55
|
+
</div>
|
|
56
|
+
)}
|
|
57
|
+
|
|
58
|
+
{/* Desktop layout */}
|
|
59
|
+
<div className={renderMobileCard ? "stats-table-desktop-only" : "stats-table-container"}>
|
|
60
|
+
<table className="stats-table">
|
|
61
|
+
<thead>
|
|
62
|
+
<tr>
|
|
63
|
+
{columns.map(col => {
|
|
64
|
+
const headerClasses = [
|
|
65
|
+
"stats-table-th",
|
|
66
|
+
col.numeric ? "stats-text-right" : "stats-text-left",
|
|
67
|
+
col.className || "",
|
|
68
|
+
]
|
|
69
|
+
.filter(Boolean)
|
|
70
|
+
.join(" ");
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<th key={col.key} className={headerClasses}>
|
|
74
|
+
{col.header}
|
|
75
|
+
</th>
|
|
76
|
+
);
|
|
77
|
+
})}
|
|
78
|
+
</tr>
|
|
79
|
+
</thead>
|
|
80
|
+
<tbody>
|
|
81
|
+
{data.map(item => {
|
|
82
|
+
const key = String(keyExtractor(item));
|
|
83
|
+
const isClickable = typeof onRowClick === "function";
|
|
84
|
+
const rowClasses = ["stats-table-tr", isClickable ? "stats-table-tr-clickable" : ""]
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
.join(" ");
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<tr
|
|
90
|
+
key={key}
|
|
91
|
+
className={rowClasses}
|
|
92
|
+
onClick={isClickable ? () => onRowClick!(item) : undefined}
|
|
93
|
+
onKeyDown={isClickable ? e => handleKeyDown(e, item) : undefined}
|
|
94
|
+
tabIndex={isClickable ? 0 : undefined}
|
|
95
|
+
role={isClickable ? "button" : undefined}
|
|
96
|
+
>
|
|
97
|
+
{columns.map(col => {
|
|
98
|
+
const cellClasses = [
|
|
99
|
+
"stats-table-td",
|
|
100
|
+
col.numeric ? "stats-text-right" : "stats-text-left",
|
|
101
|
+
col.className || "",
|
|
102
|
+
]
|
|
103
|
+
.filter(Boolean)
|
|
104
|
+
.join(" ");
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<td key={col.key} className={cellClasses}>
|
|
108
|
+
{col.render
|
|
109
|
+
? col.render(item)
|
|
110
|
+
: ((item as Record<string, unknown>)[col.key] as React.ReactNode)}
|
|
111
|
+
</td>
|
|
112
|
+
);
|
|
113
|
+
})}
|
|
114
|
+
</tr>
|
|
115
|
+
);
|
|
116
|
+
})}
|
|
117
|
+
</tbody>
|
|
118
|
+
</table>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Inbox, type LucideIcon } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
export interface EmptyStateProps {
|
|
4
|
+
message?: string;
|
|
5
|
+
icon?: LucideIcon;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function EmptyState({ message = "No data available", icon: Icon = Inbox, className = "" }: EmptyStateProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className={`stats-empty-state ${className}`}>
|
|
12
|
+
<Icon size={24} className="stats-empty-state-icon" aria-hidden="true" />
|
|
13
|
+
<p className="stats-empty-state-message">{message}</p>
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export interface ErrorStateProps {
|
|
2
|
+
error?: Error | null;
|
|
3
|
+
onRetry?: () => void;
|
|
4
|
+
className?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function ErrorState({ error, onRetry, className = "" }: ErrorStateProps) {
|
|
8
|
+
return (
|
|
9
|
+
<div className={`stats-error-state ${className}`}>
|
|
10
|
+
<div className="stats-error-state-content">
|
|
11
|
+
<h4 className="stats-error-state-title">Failed to load data</h4>
|
|
12
|
+
{error && <p className="stats-error-state-message">{error.message}</p>}
|
|
13
|
+
{onRetry && (
|
|
14
|
+
<button
|
|
15
|
+
type="button"
|
|
16
|
+
onClick={onRetry}
|
|
17
|
+
className="stats-button stats-button-secondary stats-error-state-btn"
|
|
18
|
+
>
|
|
19
|
+
Retry
|
|
20
|
+
</button>
|
|
21
|
+
)}
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { Check, Copy } from "lucide-react";
|
|
2
|
+
import type React from "react";
|
|
3
|
+
import { useEffect, useRef, useState } from "react";
|
|
4
|
+
|
|
5
|
+
export interface JsonBlockProps {
|
|
6
|
+
data: unknown;
|
|
7
|
+
title?: string;
|
|
8
|
+
initialCollapsed?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function JsonBlock({ data, title, initialCollapsed = false }: JsonBlockProps) {
|
|
12
|
+
const [collapsed, setCollapsed] = useState(initialCollapsed);
|
|
13
|
+
const [copied, setCopied] = useState(false);
|
|
14
|
+
const copyResetRef = useRef<number>(0);
|
|
15
|
+
const jsonStr = JSON.stringify(data, null, 2);
|
|
16
|
+
|
|
17
|
+
// Clear the pending "Copied" reset if the block unmounts (e.g. drawer close).
|
|
18
|
+
useEffect(() => () => window.clearTimeout(copyResetRef.current), []);
|
|
19
|
+
|
|
20
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
21
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
22
|
+
e.preventDefault();
|
|
23
|
+
setCollapsed(!collapsed);
|
|
24
|
+
}
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const handleCopy = async (e: React.MouseEvent) => {
|
|
28
|
+
// Don't toggle the collapse state when copying.
|
|
29
|
+
e.stopPropagation();
|
|
30
|
+
try {
|
|
31
|
+
await navigator.clipboard.writeText(jsonStr);
|
|
32
|
+
setCopied(true);
|
|
33
|
+
window.clearTimeout(copyResetRef.current);
|
|
34
|
+
copyResetRef.current = window.setTimeout(() => setCopied(false), 1500);
|
|
35
|
+
} catch {
|
|
36
|
+
// Clipboard API unavailable (e.g. insecure context); silently no-op.
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="stats-json-block">
|
|
42
|
+
<div
|
|
43
|
+
className="stats-json-block-header"
|
|
44
|
+
onClick={() => setCollapsed(!collapsed)}
|
|
45
|
+
onKeyDown={handleKeyDown}
|
|
46
|
+
tabIndex={0}
|
|
47
|
+
role="button"
|
|
48
|
+
aria-expanded={!collapsed}
|
|
49
|
+
>
|
|
50
|
+
<span className="stats-json-block-title">{title || "JSON"}</span>
|
|
51
|
+
<div className="stats-json-actions">
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
className="stats-json-copy-btn"
|
|
55
|
+
onClick={handleCopy}
|
|
56
|
+
aria-label={copied ? "Copied to clipboard" : "Copy JSON to clipboard"}
|
|
57
|
+
>
|
|
58
|
+
{copied ? <Check size={13} /> : <Copy size={13} />}
|
|
59
|
+
{copied ? "Copied" : "Copy"}
|
|
60
|
+
</button>
|
|
61
|
+
<span className="stats-json-block-toggle-indicator" data-collapsed={collapsed}>
|
|
62
|
+
{collapsed ? "▶ Show" : "▼ Hide"}
|
|
63
|
+
</span>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
{!collapsed && (
|
|
67
|
+
<div className="stats-json-block-content-wrapper">
|
|
68
|
+
<pre className="stats-json-block-content">
|
|
69
|
+
<code>{jsonStr}</code>
|
|
70
|
+
</pre>
|
|
71
|
+
</div>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatCompact,
|
|
3
|
+
formatCost,
|
|
4
|
+
formatDurationMs,
|
|
5
|
+
formatInteger,
|
|
6
|
+
formatPercent,
|
|
7
|
+
formatTokensPerSecond,
|
|
8
|
+
} from "../data/formatters";
|
|
9
|
+
import type { AggregatedStats } from "../types";
|
|
10
|
+
|
|
11
|
+
export interface MetricClusterProps {
|
|
12
|
+
stats: AggregatedStats;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function MetricCluster({ stats }: MetricClusterProps) {
|
|
16
|
+
return (
|
|
17
|
+
<div className="stats-metric-cluster">
|
|
18
|
+
<div className="stats-metric-primary-grid">
|
|
19
|
+
<div className="stats-metric-card primary">
|
|
20
|
+
<div className="stats-metric-label">Total Cost</div>
|
|
21
|
+
<div className="stats-metric-value">
|
|
22
|
+
{formatCost(stats.totalCost, stats.totalCost > 0 && stats.totalCost < 0.01 ? 4 : 2)}
|
|
23
|
+
</div>
|
|
24
|
+
</div>
|
|
25
|
+
<div className="stats-metric-card primary">
|
|
26
|
+
<div className="stats-metric-label">Requests</div>
|
|
27
|
+
<div className="stats-metric-value">{formatInteger(stats.totalRequests)}</div>
|
|
28
|
+
</div>
|
|
29
|
+
<div className="stats-metric-card primary">
|
|
30
|
+
<div className="stats-metric-label">Cache Rate</div>
|
|
31
|
+
<div className="stats-metric-value">{formatPercent(stats.cacheRate)}</div>
|
|
32
|
+
</div>
|
|
33
|
+
<div className="stats-metric-card primary">
|
|
34
|
+
<div className="stats-metric-label">Error Rate</div>
|
|
35
|
+
<div className="stats-metric-value">{formatPercent(stats.errorRate)}</div>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<div className="stats-metric-secondary-grid">
|
|
40
|
+
<div className="stats-metric-card secondary">
|
|
41
|
+
<div className="stats-metric-label">Input Tokens</div>
|
|
42
|
+
<div className="stats-metric-value">{formatCompact(stats.totalInputTokens)}</div>
|
|
43
|
+
</div>
|
|
44
|
+
<div className="stats-metric-card secondary">
|
|
45
|
+
<div className="stats-metric-label">Output Tokens</div>
|
|
46
|
+
<div className="stats-metric-value">{formatCompact(stats.totalOutputTokens)}</div>
|
|
47
|
+
</div>
|
|
48
|
+
<div className="stats-metric-card secondary">
|
|
49
|
+
<div className="stats-metric-label">Premium Requests</div>
|
|
50
|
+
<div className="stats-metric-value">{formatInteger(stats.totalPremiumRequests)}</div>
|
|
51
|
+
</div>
|
|
52
|
+
<div className="stats-metric-card secondary">
|
|
53
|
+
<div className="stats-metric-label">Tokens/s</div>
|
|
54
|
+
<div className="stats-metric-value">{formatTokensPerSecond(stats.avgTokensPerSecond)}</div>
|
|
55
|
+
</div>
|
|
56
|
+
<div className="stats-metric-card secondary">
|
|
57
|
+
<div className="stats-metric-label">Avg Latency</div>
|
|
58
|
+
<div className="stats-metric-value">{formatDurationMs(stats.avgDuration)}</div>
|
|
59
|
+
</div>
|
|
60
|
+
<div className="stats-metric-card secondary">
|
|
61
|
+
<div className="stats-metric-label">Avg TTFT</div>
|
|
62
|
+
<div className="stats-metric-value">{formatDurationMs(stats.avgTtft)}</div>
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
|
|
3
|
+
export interface PanelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
|
|
4
|
+
title?: React.ReactNode;
|
|
5
|
+
subtitle?: React.ReactNode;
|
|
6
|
+
actions?: React.ReactNode;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function Panel({ title, subtitle, actions, children, className = "", ...props }: PanelProps) {
|
|
10
|
+
return (
|
|
11
|
+
<div className={`stats-panel ${className}`} {...props}>
|
|
12
|
+
{(title || subtitle || actions) && (
|
|
13
|
+
<div className="stats-panel-header">
|
|
14
|
+
<div className="stats-panel-header-titles">
|
|
15
|
+
{title && <h3 className="stats-panel-title">{title}</h3>}
|
|
16
|
+
{subtitle && <p className="stats-panel-subtitle">{subtitle}</p>}
|
|
17
|
+
</div>
|
|
18
|
+
{actions && <div className="stats-panel-actions">{actions}</div>}
|
|
19
|
+
</div>
|
|
20
|
+
)}
|
|
21
|
+
<div className="stats-panel-body">{children}</div>
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { Clock, Coins, Gauge, Hash, Star, X, Zap } from "lucide-react";
|
|
2
|
+
import { useEffect, useRef, useState } from "react";
|
|
3
|
+
import { getRequestDetails } from "../api";
|
|
4
|
+
import { formatCost, formatDurationMs, formatInteger } from "../data/formatters";
|
|
5
|
+
import type { RequestDetails } from "../types";
|
|
6
|
+
import { JsonBlock } from "./JsonBlock";
|
|
7
|
+
import { Skeleton } from "./Skeleton";
|
|
8
|
+
import { StatusPill } from "./StatusPill";
|
|
9
|
+
|
|
10
|
+
export interface RequestDrawerProps {
|
|
11
|
+
id: number | null;
|
|
12
|
+
onClose: () => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function RequestDrawer({ id, onClose }: RequestDrawerProps) {
|
|
16
|
+
const [details, setDetails] = useState<RequestDetails | null>(null);
|
|
17
|
+
const [loading, setLoading] = useState(false);
|
|
18
|
+
const [error, setError] = useState<Error | null>(null);
|
|
19
|
+
const previousActiveElement = useRef<HTMLElement | null>(null);
|
|
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;
|
|
54
|
+
|
|
55
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
56
|
+
if (e.key === "Escape") {
|
|
57
|
+
onClose();
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
62
|
+
return () => {
|
|
63
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
64
|
+
if (previousActiveElement.current) {
|
|
65
|
+
previousActiveElement.current.focus();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}, [id, onClose]);
|
|
69
|
+
|
|
70
|
+
if (id === null) return null;
|
|
71
|
+
|
|
72
|
+
const handleOverlayClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
|
73
|
+
if (e.target === e.currentTarget) {
|
|
74
|
+
onClose();
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<div className="stats-drawer-overlay" onClick={handleOverlayClick} role="presentation">
|
|
80
|
+
<div className="stats-drawer" role="dialog" aria-modal="true" aria-label="Request details">
|
|
81
|
+
{/* Drawer Header */}
|
|
82
|
+
<div className="stats-drawer-header">
|
|
83
|
+
<div className="stats-drawer-header-left">
|
|
84
|
+
<h2 className="stats-drawer-title">Request Details</h2>
|
|
85
|
+
{details && <span className="stats-drawer-id">ID: {id}</span>}
|
|
86
|
+
</div>
|
|
87
|
+
<button
|
|
88
|
+
ref={closeButtonRef}
|
|
89
|
+
type="button"
|
|
90
|
+
onClick={onClose}
|
|
91
|
+
className="stats-drawer-close-btn"
|
|
92
|
+
aria-label="Close request details"
|
|
93
|
+
>
|
|
94
|
+
<X size={18} />
|
|
95
|
+
</button>
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div className="stats-drawer-body">
|
|
99
|
+
{loading && (
|
|
100
|
+
<div className="stats-drawer-loading">
|
|
101
|
+
<Skeleton variant="text" width="60%" height={24} className="mb-4" />
|
|
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
|
+
Cost
|
|
142
|
+
</div>
|
|
143
|
+
<div className="stats-drawer-metric-value">{formatCost(details.usage.cost.total, 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>
|
|
207
|
+
);
|
|
208
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export interface SegmentedControlOption<T> {
|
|
2
|
+
value: T;
|
|
3
|
+
label: string;
|
|
4
|
+
title?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface SegmentedControlProps<T> {
|
|
8
|
+
options: SegmentedControlOption<T>[];
|
|
9
|
+
value: T;
|
|
10
|
+
onChange: (value: T) => void;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function SegmentedControl<T>({ options, value, onChange, className = "" }: SegmentedControlProps<T>) {
|
|
15
|
+
return (
|
|
16
|
+
<div className={`stats-segmented-control ${className}`} role="radiogroup">
|
|
17
|
+
{options.map(opt => {
|
|
18
|
+
const isActive = opt.value === value;
|
|
19
|
+
return (
|
|
20
|
+
<button
|
|
21
|
+
key={String(opt.value)}
|
|
22
|
+
type="button"
|
|
23
|
+
role="radio"
|
|
24
|
+
aria-checked={isActive}
|
|
25
|
+
data-active={isActive ? "true" : "false"}
|
|
26
|
+
className="stats-segmented-control-btn"
|
|
27
|
+
title={opt.title}
|
|
28
|
+
onClick={() => onChange(opt.value)}
|
|
29
|
+
>
|
|
30
|
+
{opt.label}
|
|
31
|
+
</button>
|
|
32
|
+
);
|
|
33
|
+
})}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
|
|
3
|
+
export interface SkeletonProps {
|
|
4
|
+
variant?: "text" | "rect" | "circle";
|
|
5
|
+
width?: string | number;
|
|
6
|
+
height?: string | number;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function Skeleton({ variant = "text", width, height, className = "" }: SkeletonProps) {
|
|
11
|
+
const style: React.CSSProperties = {
|
|
12
|
+
width: width !== undefined ? (typeof width === "number" ? `${width}px` : width) : undefined,
|
|
13
|
+
height: height !== undefined ? (typeof height === "number" ? `${height}px` : height) : undefined,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
return <div className={`stats-skeleton ${className}`} data-variant={variant} style={style} />;
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
|
|
3
|
+
export interface StatusPillProps {
|
|
4
|
+
variant: "success" | "danger" | "warning" | "info" | "default";
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function StatusPill({ variant, children, className = "" }: StatusPillProps) {
|
|
10
|
+
return (
|
|
11
|
+
<span className={`stats-status-pill ${className}`} data-variant={variant}>
|
|
12
|
+
{children}
|
|
13
|
+
</span>
|
|
14
|
+
);
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export * from "./AsyncBoundary";
|
|
2
|
+
export * from "./DataTable";
|
|
3
|
+
export * from "./EmptyState";
|
|
4
|
+
export * from "./ErrorState";
|
|
5
|
+
export * from "./JsonBlock";
|
|
6
|
+
export * from "./MetricCluster";
|
|
7
|
+
export * from "./Panel";
|
|
8
|
+
export * from "./RequestDrawer";
|
|
9
|
+
export * from "./SegmentedControl";
|
|
10
|
+
export * from "./Skeleton";
|
|
11
|
+
export * from "./StatusPill";
|