@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.
Files changed (120) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/README.md +82 -0
  3. package/build.ts +95 -0
  4. package/dist/client/index.css +1 -0
  5. package/dist/client/index.html +24 -0
  6. package/dist/client/index.js +257 -0
  7. package/dist/client/styles.css +1656 -0
  8. package/dist/types/aggregator.d.ts +87 -0
  9. package/dist/types/client/App.d.ts +1 -0
  10. package/dist/types/client/api.d.ts +21 -0
  11. package/dist/types/client/app/AppLayout.d.ts +16 -0
  12. package/dist/types/client/app/NavRail.d.ts +7 -0
  13. package/dist/types/client/app/RangeControl.d.ts +7 -0
  14. package/dist/types/client/app/SyncButton.d.ts +14 -0
  15. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  16. package/dist/types/client/app/TopBar.d.ts +15 -0
  17. package/dist/types/client/app/routes.d.ts +12 -0
  18. package/dist/types/client/components/AgentTokenShare.d.ts +5 -0
  19. package/dist/types/client/components/chart-shared.d.ts +173 -0
  20. package/dist/types/client/components/models-table-shared.d.ts +175 -0
  21. package/dist/types/client/components/range-meta.d.ts +21 -0
  22. package/dist/types/client/data/charts.d.ts +1 -0
  23. package/dist/types/client/data/formatters.d.ts +8 -0
  24. package/dist/types/client/data/useHashRoute.d.ts +8 -0
  25. package/dist/types/client/data/useResource.d.ts +13 -0
  26. package/dist/types/client/data/view-models.d.ts +67 -0
  27. package/dist/types/client/index.d.ts +2 -0
  28. package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
  29. package/dist/types/client/routes/CostsRoute.d.ts +7 -0
  30. package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
  31. package/dist/types/client/routes/GainRoute.d.ts +7 -0
  32. package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
  33. package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
  34. package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
  35. package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
  36. package/dist/types/client/routes/ToolsRoute.d.ts +7 -0
  37. package/dist/types/client/routes/index.d.ts +9 -0
  38. package/dist/types/client/types.d.ts +63 -0
  39. package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
  40. package/dist/types/client/ui/DataTable.d.ts +17 -0
  41. package/dist/types/client/ui/EmptyState.d.ts +7 -0
  42. package/dist/types/client/ui/ErrorState.d.ts +6 -0
  43. package/dist/types/client/ui/JsonBlock.d.ts +7 -0
  44. package/dist/types/client/ui/MetricCluster.d.ts +5 -0
  45. package/dist/types/client/ui/Panel.d.ts +7 -0
  46. package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
  47. package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
  48. package/dist/types/client/ui/Skeleton.d.ts +8 -0
  49. package/dist/types/client/ui/StatusPill.d.ts +7 -0
  50. package/dist/types/client/ui/index.d.ts +11 -0
  51. package/dist/types/client/useSystemTheme.d.ts +11 -0
  52. package/dist/types/db.d.ts +144 -0
  53. package/dist/types/embedded-client.d.ts +18 -0
  54. package/dist/types/gain-aggregator.d.ts +26 -0
  55. package/dist/types/index.d.ts +7 -0
  56. package/dist/types/parser.d.ts +53 -0
  57. package/dist/types/server.d.ts +7 -0
  58. package/dist/types/shared-types.d.ts +301 -0
  59. package/dist/types/sync-worker.d.ts +31 -0
  60. package/dist/types/types.d.ts +164 -0
  61. package/dist/types/user-metrics.d.ts +72 -0
  62. package/package.json +95 -0
  63. package/src/aggregator.ts +501 -0
  64. package/src/client/App.tsx +109 -0
  65. package/src/client/api.ts +102 -0
  66. package/src/client/app/AppLayout.tsx +93 -0
  67. package/src/client/app/NavRail.tsx +44 -0
  68. package/src/client/app/RangeControl.tsx +39 -0
  69. package/src/client/app/SyncButton.tsx +75 -0
  70. package/src/client/app/ThemeToggle.tsx +37 -0
  71. package/src/client/app/TopBar.tsx +73 -0
  72. package/src/client/app/routes.ts +69 -0
  73. package/src/client/components/AgentTokenShare.tsx +68 -0
  74. package/src/client/components/chart-shared.tsx +257 -0
  75. package/src/client/components/models-table-shared.tsx +255 -0
  76. package/src/client/components/range-meta.ts +73 -0
  77. package/src/client/css.d.ts +1 -0
  78. package/src/client/data/charts.ts +14 -0
  79. package/src/client/data/formatters.ts +45 -0
  80. package/src/client/data/useHashRoute.ts +87 -0
  81. package/src/client/data/useResource.ts +154 -0
  82. package/src/client/data/view-models.ts +251 -0
  83. package/src/client/index.tsx +10 -0
  84. package/src/client/routes/BehaviorRoute.tsx +623 -0
  85. package/src/client/routes/CostsRoute.tsx +234 -0
  86. package/src/client/routes/ErrorsRoute.tsx +118 -0
  87. package/src/client/routes/GainRoute.tsx +226 -0
  88. package/src/client/routes/ModelsRoute.tsx +430 -0
  89. package/src/client/routes/OverviewRoute.tsx +342 -0
  90. package/src/client/routes/ProjectsRoute.tsx +163 -0
  91. package/src/client/routes/RequestsRoute.tsx +123 -0
  92. package/src/client/routes/ToolsRoute.tsx +463 -0
  93. package/src/client/routes/index.ts +9 -0
  94. package/src/client/styles.css +1330 -0
  95. package/src/client/types.ts +80 -0
  96. package/src/client/ui/AsyncBoundary.tsx +54 -0
  97. package/src/client/ui/DataTable.tsx +122 -0
  98. package/src/client/ui/EmptyState.tsx +16 -0
  99. package/src/client/ui/ErrorState.tsx +25 -0
  100. package/src/client/ui/JsonBlock.tsx +75 -0
  101. package/src/client/ui/MetricCluster.tsx +67 -0
  102. package/src/client/ui/Panel.tsx +24 -0
  103. package/src/client/ui/RequestDrawer.tsx +208 -0
  104. package/src/client/ui/SegmentedControl.tsx +36 -0
  105. package/src/client/ui/Skeleton.tsx +17 -0
  106. package/src/client/ui/StatusPill.tsx +15 -0
  107. package/src/client/ui/index.ts +11 -0
  108. package/src/client/useSystemTheme.ts +87 -0
  109. package/src/db.ts +1530 -0
  110. package/src/embedded-client.generated.txt +0 -0
  111. package/src/embedded-client.ts +26 -0
  112. package/src/gain-aggregator.ts +281 -0
  113. package/src/index.ts +194 -0
  114. package/src/parser.ts +450 -0
  115. package/src/server.ts +352 -0
  116. package/src/shared-types.ts +323 -0
  117. package/src/sync-worker.ts +40 -0
  118. package/src/types.ts +171 -0
  119. package/src/user-metrics.ts +685 -0
  120. 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
+ }