@oh-my-pi-zen/omp-stats 16.3.6-zen.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 +197 -0
- package/README.md +82 -0
- package/build.ts +95 -0
- package/dist/client/index.css +1 -0
- package/dist/client/index.html +24 -0
- package/dist/client/index.js +257 -0
- package/dist/client/styles.css +1656 -0
- package/dist/types/aggregator.d.ts +87 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/api.d.ts +21 -0
- 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/AgentTokenShare.d.ts +5 -0
- package/dist/types/client/components/chart-shared.d.ts +173 -0
- package/dist/types/client/components/models-table-shared.d.ts +175 -0
- package/dist/types/client/components/range-meta.d.ts +21 -0
- package/dist/types/client/data/charts.d.ts +1 -0
- package/dist/types/client/data/formatters.d.ts +8 -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 +67 -0
- package/dist/types/client/index.d.ts +2 -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/GainRoute.d.ts +7 -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/ToolsRoute.d.ts +7 -0
- package/dist/types/client/routes/index.d.ts +9 -0
- package/dist/types/client/types.d.ts +63 -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 +11 -0
- package/dist/types/db.d.ts +144 -0
- package/dist/types/embedded-client.d.ts +18 -0
- package/dist/types/gain-aggregator.d.ts +26 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/parser.d.ts +53 -0
- package/dist/types/server.d.ts +7 -0
- package/dist/types/shared-types.d.ts +301 -0
- package/dist/types/sync-worker.d.ts +31 -0
- package/dist/types/types.d.ts +164 -0
- package/dist/types/user-metrics.d.ts +72 -0
- package/package.json +95 -0
- package/src/aggregator.ts +501 -0
- package/src/client/App.tsx +109 -0
- package/src/client/api.ts +102 -0
- 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 +69 -0
- package/src/client/components/AgentTokenShare.tsx +68 -0
- package/src/client/components/chart-shared.tsx +257 -0
- package/src/client/components/models-table-shared.tsx +255 -0
- package/src/client/components/range-meta.ts +73 -0
- package/src/client/css.d.ts +1 -0
- package/src/client/data/charts.ts +14 -0
- package/src/client/data/formatters.ts +45 -0
- package/src/client/data/useHashRoute.ts +87 -0
- package/src/client/data/useResource.ts +154 -0
- package/src/client/data/view-models.ts +251 -0
- package/src/client/index.tsx +10 -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/GainRoute.tsx +226 -0
- package/src/client/routes/ModelsRoute.tsx +430 -0
- package/src/client/routes/OverviewRoute.tsx +342 -0
- package/src/client/routes/ProjectsRoute.tsx +163 -0
- package/src/client/routes/RequestsRoute.tsx +123 -0
- package/src/client/routes/ToolsRoute.tsx +463 -0
- package/src/client/routes/index.ts +9 -0
- package/src/client/styles.css +1330 -0
- package/src/client/types.ts +80 -0
- 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 +87 -0
- package/src/db.ts +1530 -0
- package/src/embedded-client.generated.txt +0 -0
- package/src/embedded-client.ts +26 -0
- package/src/gain-aggregator.ts +281 -0
- package/src/index.ts +194 -0
- package/src/parser.ts +450 -0
- package/src/server.ts +352 -0
- package/src/shared-types.ts +323 -0
- package/src/sync-worker.ts +40 -0
- package/src/types.ts +171 -0
- package/src/user-metrics.ts +685 -0
- package/tailwind.config.js +40 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side type definitions.
|
|
3
|
+
*
|
|
4
|
+
* Shared shapes (aggregations, time-series, dashboard payloads) live in
|
|
5
|
+
* `../shared-types` and are re-exported here. The types declared inline below
|
|
6
|
+
* are deliberately client-only because:
|
|
7
|
+
* - `Usage` is redeclared locally so the client bundle avoids importing
|
|
8
|
+
* `@oh-my-pi-zen/pi-ai` (the server-side AI types package).
|
|
9
|
+
* - `MessageStats.stopReason` is widened from the server's `StopReason`
|
|
10
|
+
* enum to `string`, again to keep the client free of pi-ai types.
|
|
11
|
+
* - `TimeRange`, `OverviewStats`, `ModelDashboardStats`,
|
|
12
|
+
* `CostDashboardStats` are UI-only view shapes the server never produces.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
AgentTypeStats,
|
|
17
|
+
AggregatedStats,
|
|
18
|
+
CostTimeSeriesPoint,
|
|
19
|
+
ModelPerformancePoint,
|
|
20
|
+
ModelStats,
|
|
21
|
+
ModelTimeSeriesPoint,
|
|
22
|
+
TimeSeriesPoint,
|
|
23
|
+
} from "../shared-types";
|
|
24
|
+
|
|
25
|
+
export * from "../shared-types";
|
|
26
|
+
|
|
27
|
+
export interface Usage {
|
|
28
|
+
input: number;
|
|
29
|
+
output: number;
|
|
30
|
+
cacheRead: number;
|
|
31
|
+
cacheWrite: number;
|
|
32
|
+
totalTokens: number;
|
|
33
|
+
premiumRequests?: number;
|
|
34
|
+
cost: {
|
|
35
|
+
input: number;
|
|
36
|
+
output: number;
|
|
37
|
+
cacheRead: number;
|
|
38
|
+
cacheWrite: number;
|
|
39
|
+
total: number;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface MessageStats {
|
|
44
|
+
id?: number;
|
|
45
|
+
sessionFile: string;
|
|
46
|
+
entryId: string;
|
|
47
|
+
folder: string;
|
|
48
|
+
model: string;
|
|
49
|
+
provider: string;
|
|
50
|
+
api: string;
|
|
51
|
+
timestamp: number;
|
|
52
|
+
duration: number | null;
|
|
53
|
+
ttft: number | null;
|
|
54
|
+
stopReason: string;
|
|
55
|
+
errorMessage: string | null;
|
|
56
|
+
usage: Usage;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface RequestDetails extends MessageStats {
|
|
60
|
+
messages: unknown[];
|
|
61
|
+
output: unknown;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type TimeRange = "1h" | "24h" | "7d" | "30d" | "90d" | "all";
|
|
65
|
+
|
|
66
|
+
export interface OverviewStats {
|
|
67
|
+
overall: AggregatedStats;
|
|
68
|
+
byAgentType: AgentTypeStats[];
|
|
69
|
+
timeSeries: TimeSeriesPoint[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface ModelDashboardStats {
|
|
73
|
+
byModel: ModelStats[];
|
|
74
|
+
modelSeries: ModelTimeSeriesPoint[];
|
|
75
|
+
modelPerformanceSeries: ModelPerformancePoint[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface CostDashboardStats {
|
|
79
|
+
costSeries: CostTimeSeriesPoint[];
|
|
80
|
+
}
|
|
@@ -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
|
+
}
|