@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,87 @@
|
|
|
1
|
+
import { useCallback, useEffect, useState } from "react";
|
|
2
|
+
import type { DashboardSection } from "../app/routes";
|
|
3
|
+
import type { TimeRange } from "../types";
|
|
4
|
+
|
|
5
|
+
const VALID_SECTIONS: DashboardSection[] = [
|
|
6
|
+
"overview",
|
|
7
|
+
"requests",
|
|
8
|
+
"errors",
|
|
9
|
+
"models",
|
|
10
|
+
"tools",
|
|
11
|
+
"costs",
|
|
12
|
+
"behavior",
|
|
13
|
+
"projects",
|
|
14
|
+
"gain",
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const VALID_RANGES: TimeRange[] = ["1h", "24h", "7d", "30d", "90d", "all"];
|
|
18
|
+
|
|
19
|
+
function parseHash(hash: string): { section: DashboardSection; range: TimeRange } {
|
|
20
|
+
const cleanHash = hash.replace(/^#\/?/, "");
|
|
21
|
+
const [pathPart, queryPart] = cleanHash.split("?");
|
|
22
|
+
|
|
23
|
+
const section: DashboardSection = (VALID_SECTIONS as string[]).includes(pathPart)
|
|
24
|
+
? (pathPart as DashboardSection)
|
|
25
|
+
: "overview";
|
|
26
|
+
|
|
27
|
+
let range: TimeRange = "24h";
|
|
28
|
+
if (queryPart) {
|
|
29
|
+
const params = new URLSearchParams(queryPart);
|
|
30
|
+
const rangeParam = params.get("range") as TimeRange;
|
|
31
|
+
if (VALID_RANGES.includes(rangeParam)) {
|
|
32
|
+
range = rangeParam;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return { section, range };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function useHashRoute() {
|
|
40
|
+
const [route, setRouteState] = useState(() => parseHash(window.location.hash));
|
|
41
|
+
|
|
42
|
+
useEffect(() => {
|
|
43
|
+
const handleHashChange = () => {
|
|
44
|
+
setRouteState(parseHash(window.location.hash));
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
window.addEventListener("hashchange", handleHashChange);
|
|
48
|
+
return () => {
|
|
49
|
+
window.removeEventListener("hashchange", handleHashChange);
|
|
50
|
+
};
|
|
51
|
+
}, []);
|
|
52
|
+
|
|
53
|
+
const updateHash = useCallback((section: string, range: TimeRange) => {
|
|
54
|
+
window.location.hash = `/${section}?range=${range}`;
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
const setSection = useCallback(
|
|
58
|
+
(newSection: DashboardSection) => {
|
|
59
|
+
updateHash(newSection, route.range);
|
|
60
|
+
},
|
|
61
|
+
[route.range, updateHash],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const setRange = useCallback(
|
|
65
|
+
(newRange: string) => {
|
|
66
|
+
const nextRange = VALID_RANGES.includes(newRange as TimeRange) ? (newRange as TimeRange) : "24h";
|
|
67
|
+
updateHash(route.section, nextRange);
|
|
68
|
+
},
|
|
69
|
+
[route.section, updateHash],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
const currentHash = window.location.hash;
|
|
74
|
+
const parsed = parseHash(currentHash);
|
|
75
|
+
const expectedHash = `#/${parsed.section}?range=${parsed.range}`;
|
|
76
|
+
if (currentHash !== expectedHash) {
|
|
77
|
+
window.location.hash = `/${parsed.section}?range=${parsed.range}`;
|
|
78
|
+
}
|
|
79
|
+
}, []);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
section: route.section,
|
|
83
|
+
setSection,
|
|
84
|
+
range: route.range,
|
|
85
|
+
setRange,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
export interface ResourceResult<T> {
|
|
4
|
+
data: T | null;
|
|
5
|
+
error: Error | null;
|
|
6
|
+
loading: boolean;
|
|
7
|
+
refreshing: boolean;
|
|
8
|
+
refetch: () => Promise<void>;
|
|
9
|
+
updatedAt: number | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ResourceOptions {
|
|
13
|
+
pollMs?: number;
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Session-scoped cache keyed by the resource key. Lets navigation between
|
|
18
|
+
// screens (and back to an already-visited range) render instantly from the
|
|
19
|
+
// last value and refresh in the background, instead of unmounting to a
|
|
20
|
+
// skeleton on every visit. Cleared only on full page reload.
|
|
21
|
+
const resourceCache = new Map<string, { data: unknown; updatedAt: number }>();
|
|
22
|
+
const RESOURCE_CACHE_LIMIT = 64;
|
|
23
|
+
|
|
24
|
+
export function useResource<T>(
|
|
25
|
+
key: readonly unknown[],
|
|
26
|
+
fetcher: (signal: AbortSignal) => Promise<T>,
|
|
27
|
+
options?: ResourceOptions,
|
|
28
|
+
): ResourceResult<T> {
|
|
29
|
+
const keyString = JSON.stringify(key);
|
|
30
|
+
|
|
31
|
+
const [data, setData] = useState<T | null>(() => (resourceCache.get(keyString)?.data as T | undefined) ?? null);
|
|
32
|
+
const [error, setError] = useState<Error | null>(null);
|
|
33
|
+
const [loading, setLoading] = useState(() => !resourceCache.has(keyString));
|
|
34
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
35
|
+
const [updatedAt, setUpdatedAt] = useState<number | null>(() => resourceCache.get(keyString)?.updatedAt ?? null);
|
|
36
|
+
|
|
37
|
+
const fetcherRef = useRef(fetcher);
|
|
38
|
+
fetcherRef.current = fetcher;
|
|
39
|
+
const keyStringRef = useRef(keyString);
|
|
40
|
+
keyStringRef.current = keyString;
|
|
41
|
+
|
|
42
|
+
const enabled = options?.enabled ?? true;
|
|
43
|
+
const pollMs = options?.pollMs;
|
|
44
|
+
|
|
45
|
+
const controllerRef = useRef<AbortController | null>(null);
|
|
46
|
+
|
|
47
|
+
// Track whether we already hold data so a key change refreshes in the
|
|
48
|
+
// background — keeping the prior view mounted so charts animate to the new
|
|
49
|
+
// data instead of flashing a skeleton.
|
|
50
|
+
const hasDataRef = useRef(false);
|
|
51
|
+
hasDataRef.current = data !== null;
|
|
52
|
+
|
|
53
|
+
const executeFetch = useCallback(async (isBackground: boolean) => {
|
|
54
|
+
if (controllerRef.current) {
|
|
55
|
+
controllerRef.current.abort();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
controllerRef.current = controller;
|
|
60
|
+
|
|
61
|
+
if (isBackground) {
|
|
62
|
+
setRefreshing(true);
|
|
63
|
+
} else {
|
|
64
|
+
setLoading(true);
|
|
65
|
+
setData(null);
|
|
66
|
+
}
|
|
67
|
+
setError(null);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const result = await fetcherRef.current(controller.signal);
|
|
71
|
+
if (controller.signal.aborted) {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
resourceCache.set(keyStringRef.current, { data: result, updatedAt: Date.now() });
|
|
75
|
+
if (resourceCache.size > RESOURCE_CACHE_LIMIT) {
|
|
76
|
+
const oldestKey = resourceCache.keys().next().value;
|
|
77
|
+
if (oldestKey !== undefined) resourceCache.delete(oldestKey);
|
|
78
|
+
}
|
|
79
|
+
setData(result);
|
|
80
|
+
setUpdatedAt(Date.now());
|
|
81
|
+
setError(null);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (controller.signal.aborted) {
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
setError(err instanceof Error ? err : new Error(String(err)));
|
|
87
|
+
} finally {
|
|
88
|
+
if (!controller.signal.aborted) {
|
|
89
|
+
setLoading(false);
|
|
90
|
+
setRefreshing(false);
|
|
91
|
+
if (controllerRef.current === controller) {
|
|
92
|
+
controllerRef.current = null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
useEffect(() => {
|
|
99
|
+
if (!enabled) {
|
|
100
|
+
setLoading(false);
|
|
101
|
+
setRefreshing(false);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const cached = resourceCache.get(keyString);
|
|
106
|
+
if (cached) {
|
|
107
|
+
// Show the cached value immediately, then revalidate in the background.
|
|
108
|
+
setData(cached.data as T);
|
|
109
|
+
setUpdatedAt(cached.updatedAt);
|
|
110
|
+
setLoading(false);
|
|
111
|
+
executeFetch(true);
|
|
112
|
+
} else {
|
|
113
|
+
// No cache: keep any stale data (range morph) or show a skeleton (first load).
|
|
114
|
+
executeFetch(hasDataRef.current);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return () => {
|
|
118
|
+
if (controllerRef.current) {
|
|
119
|
+
controllerRef.current.abort();
|
|
120
|
+
controllerRef.current = null;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}, [keyString, enabled, executeFetch]);
|
|
124
|
+
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (!enabled || !pollMs) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const interval = setInterval(() => {
|
|
131
|
+
if (document.hidden) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
void executeFetch(true);
|
|
135
|
+
}, pollMs);
|
|
136
|
+
|
|
137
|
+
return () => {
|
|
138
|
+
clearInterval(interval);
|
|
139
|
+
};
|
|
140
|
+
}, [enabled, pollMs, executeFetch]);
|
|
141
|
+
|
|
142
|
+
const refetch = useCallback(async () => {
|
|
143
|
+
await executeFetch(true);
|
|
144
|
+
}, [executeFetch]);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
data,
|
|
148
|
+
error,
|
|
149
|
+
loading,
|
|
150
|
+
refreshing,
|
|
151
|
+
refetch,
|
|
152
|
+
updatedAt,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { rangeMeta } from "../components/range-meta";
|
|
2
|
+
import type {
|
|
3
|
+
AgentType,
|
|
4
|
+
AgentTypeStats,
|
|
5
|
+
BehaviorOverallStats,
|
|
6
|
+
BehaviorTimeSeriesPoint,
|
|
7
|
+
CostTimeSeriesPoint,
|
|
8
|
+
FolderStats,
|
|
9
|
+
ModelPerformancePoint,
|
|
10
|
+
TimeRange,
|
|
11
|
+
ToolUsageStats,
|
|
12
|
+
} from "../types";
|
|
13
|
+
|
|
14
|
+
/** Fixed display order for the agent-token-share breakdown. */
|
|
15
|
+
const AGENT_TYPE_ORDER: AgentType[] = ["main", "subagent", "advisor"];
|
|
16
|
+
|
|
17
|
+
export interface AgentTokenSegment {
|
|
18
|
+
agentType: AgentType;
|
|
19
|
+
/** input + output + cache read + cache write — the displayed denominator. */
|
|
20
|
+
tokens: number;
|
|
21
|
+
requests: number;
|
|
22
|
+
cost: number;
|
|
23
|
+
/** Fraction (0-1) of total tokens across all present agent types. */
|
|
24
|
+
share: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface AgentTokenShareView {
|
|
28
|
+
totalTokens: number;
|
|
29
|
+
totalCost: number;
|
|
30
|
+
segments: AgentTokenSegment[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Build the "token usage by agent" breakdown: one segment per agent type that
|
|
35
|
+
* appears in the data, ordered main -> subagents -> advisor, each carrying its
|
|
36
|
+
* token total and share of the grand total. Token counts sum the same four
|
|
37
|
+
* columns the overview renders (input + output + cache read + cache write) so a
|
|
38
|
+
* segment's share never disagrees with the count beside it.
|
|
39
|
+
*/
|
|
40
|
+
export function buildAgentTokenShare(stats: AgentTypeStats[]): AgentTokenShareView {
|
|
41
|
+
const byType = new Map<AgentType, AgentTypeStats>();
|
|
42
|
+
for (const stat of stats) byType.set(stat.agentType, stat);
|
|
43
|
+
|
|
44
|
+
const tokensOf = (stat: AgentTypeStats) =>
|
|
45
|
+
stat.totalInputTokens + stat.totalOutputTokens + stat.totalCacheReadTokens + stat.totalCacheWriteTokens;
|
|
46
|
+
|
|
47
|
+
const present = AGENT_TYPE_ORDER.map(type => byType.get(type)).filter(
|
|
48
|
+
(stat): stat is AgentTypeStats => stat !== undefined,
|
|
49
|
+
);
|
|
50
|
+
const totalTokens = present.reduce((sum, stat) => sum + tokensOf(stat), 0);
|
|
51
|
+
const totalCost = present.reduce((sum, stat) => sum + stat.totalCost, 0);
|
|
52
|
+
|
|
53
|
+
const segments = present.map(stat => {
|
|
54
|
+
const tokens = tokensOf(stat);
|
|
55
|
+
return {
|
|
56
|
+
agentType: stat.agentType,
|
|
57
|
+
tokens,
|
|
58
|
+
requests: stat.totalRequests,
|
|
59
|
+
cost: stat.totalCost,
|
|
60
|
+
share: totalTokens > 0 ? tokens / totalTokens : 0,
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return { totalTokens, totalCost, segments };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CostSummaryView {
|
|
68
|
+
totalCost: number;
|
|
69
|
+
avgDailyCost: number;
|
|
70
|
+
topModelName: string;
|
|
71
|
+
topModelCost: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface ModelPerformanceDataPoint {
|
|
75
|
+
timestamp: number;
|
|
76
|
+
avgTtftSeconds: number | null;
|
|
77
|
+
avgTokensPerSecond: number | null;
|
|
78
|
+
requests: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface ModelPerformanceSeries {
|
|
82
|
+
label: string;
|
|
83
|
+
data: ModelPerformanceDataPoint[];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface BehaviorSummaryView {
|
|
87
|
+
totalMessages: number;
|
|
88
|
+
totalYelling: number;
|
|
89
|
+
totalProfanity: number;
|
|
90
|
+
totalAnguish: number;
|
|
91
|
+
totalFrustration: number;
|
|
92
|
+
highestFrictionModel: {
|
|
93
|
+
model: string;
|
|
94
|
+
provider: string;
|
|
95
|
+
score: number;
|
|
96
|
+
} | null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface FolderRowView extends FolderStats {
|
|
100
|
+
costPercentage: number;
|
|
101
|
+
requestsPercentage: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function buildCostSummary(costSeries: CostTimeSeriesPoint[]): CostSummaryView {
|
|
105
|
+
const totalCost = costSeries.reduce((sum, p) => sum + p.cost, 0);
|
|
106
|
+
const dayBuckets = new Set(costSeries.map(p => p.timestamp)).size;
|
|
107
|
+
const avgDailyCost = dayBuckets > 0 ? totalCost / dayBuckets : 0;
|
|
108
|
+
|
|
109
|
+
const modelTotals = new Map<string, number>();
|
|
110
|
+
for (const point of costSeries) {
|
|
111
|
+
modelTotals.set(point.model, (modelTotals.get(point.model) ?? 0) + point.cost);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let topModelName = "";
|
|
115
|
+
let topModelCost = 0;
|
|
116
|
+
for (const [model, cost] of modelTotals) {
|
|
117
|
+
if (cost > topModelCost) {
|
|
118
|
+
topModelName = model;
|
|
119
|
+
topModelCost = cost;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
totalCost,
|
|
125
|
+
avgDailyCost,
|
|
126
|
+
topModelName,
|
|
127
|
+
topModelCost,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function buildModelPerformanceLookup(
|
|
132
|
+
points: ModelPerformancePoint[],
|
|
133
|
+
range: TimeRange,
|
|
134
|
+
): Map<string, ModelPerformanceSeries> {
|
|
135
|
+
if (points.length === 0) return new Map();
|
|
136
|
+
|
|
137
|
+
const meta = rangeMeta(range);
|
|
138
|
+
const bucketMs = meta.bucketMs;
|
|
139
|
+
const bucketCount = meta.bucketCount;
|
|
140
|
+
|
|
141
|
+
const buckets =
|
|
142
|
+
bucketCount > 0
|
|
143
|
+
? (() => {
|
|
144
|
+
const maxTimestamp = points.reduce((max, point) => Math.max(max, point.timestamp), 0);
|
|
145
|
+
const anchor = maxTimestamp > 0 ? maxTimestamp : Math.floor(Date.now() / bucketMs) * bucketMs;
|
|
146
|
+
const start = anchor - (bucketCount - 1) * bucketMs;
|
|
147
|
+
return Array.from({ length: bucketCount }, (_, index) => start + index * bucketMs);
|
|
148
|
+
})()
|
|
149
|
+
: Array.from(new Set(points.map(p => p.timestamp))).sort((a, b) => a - b);
|
|
150
|
+
const bucketIndex = new Map(buckets.map((timestamp, index) => [timestamp, index]));
|
|
151
|
+
const seriesByKey = new Map<string, ModelPerformanceSeries>();
|
|
152
|
+
|
|
153
|
+
for (const point of points) {
|
|
154
|
+
const key = `${point.model}::${point.provider}`;
|
|
155
|
+
let series = seriesByKey.get(key);
|
|
156
|
+
if (!series) {
|
|
157
|
+
series = {
|
|
158
|
+
label: `${point.model} (${point.provider})`,
|
|
159
|
+
data: buckets.map(timestamp => ({
|
|
160
|
+
timestamp,
|
|
161
|
+
avgTtftSeconds: null,
|
|
162
|
+
avgTokensPerSecond: null,
|
|
163
|
+
requests: 0,
|
|
164
|
+
})),
|
|
165
|
+
};
|
|
166
|
+
seriesByKey.set(key, series);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const index = bucketIndex.get(point.timestamp);
|
|
170
|
+
if (index === undefined) continue;
|
|
171
|
+
|
|
172
|
+
series.data[index] = {
|
|
173
|
+
timestamp: point.timestamp,
|
|
174
|
+
avgTtftSeconds: point.avgTtft !== null ? point.avgTtft / 1000 : null,
|
|
175
|
+
avgTokensPerSecond: point.avgTokensPerSecond,
|
|
176
|
+
requests: point.requests,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return seriesByKey;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function buildBehaviorSummary(
|
|
184
|
+
overall: BehaviorOverallStats,
|
|
185
|
+
series: BehaviorTimeSeriesPoint[],
|
|
186
|
+
): BehaviorSummaryView {
|
|
187
|
+
const totalFrustration = overall.totalNegation + overall.totalRepetition + overall.totalBlame;
|
|
188
|
+
|
|
189
|
+
const totals = new Map<string, { model: string; provider: string; score: number }>();
|
|
190
|
+
for (const point of series) {
|
|
191
|
+
const key = `${point.model}::${point.provider}`;
|
|
192
|
+
const existing = totals.get(key);
|
|
193
|
+
const score = point.yelling + point.profanity + point.anguish + point.negation + point.repetition + point.blame;
|
|
194
|
+
if (existing) {
|
|
195
|
+
existing.score += score;
|
|
196
|
+
} else {
|
|
197
|
+
totals.set(key, { model: point.model, provider: point.provider, score });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let highestFrictionModel: { model: string; provider: string; score: number } | null = null;
|
|
202
|
+
for (const entry of totals.values()) {
|
|
203
|
+
if (!highestFrictionModel || entry.score > highestFrictionModel.score) {
|
|
204
|
+
highestFrictionModel = entry;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
totalMessages: overall.totalMessages,
|
|
210
|
+
totalYelling: overall.totalYelling,
|
|
211
|
+
totalProfanity: overall.totalProfanity,
|
|
212
|
+
totalAnguish: overall.totalAnguish,
|
|
213
|
+
totalFrustration,
|
|
214
|
+
highestFrictionModel,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export function buildFolderRows(folders: FolderStats[]): FolderRowView[] {
|
|
219
|
+
const sorted = [...folders].sort((a, b) => {
|
|
220
|
+
if (b.totalCost !== a.totalCost) {
|
|
221
|
+
return b.totalCost - a.totalCost;
|
|
222
|
+
}
|
|
223
|
+
return b.totalRequests - a.totalRequests;
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const maxCost = sorted.reduce((max, f) => Math.max(max, f.totalCost), 0);
|
|
227
|
+
const maxRequests = sorted.reduce((max, f) => Math.max(max, f.totalRequests), 0);
|
|
228
|
+
|
|
229
|
+
return sorted.map(f => ({
|
|
230
|
+
...f,
|
|
231
|
+
costPercentage: maxCost > 0 ? (f.totalCost / maxCost) * 100 : 0,
|
|
232
|
+
requestsPercentage: maxRequests > 0 ? (f.totalRequests / maxRequests) * 100 : 0,
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Table row for the Tools route: usage stats plus derived rates/shares. */
|
|
237
|
+
export interface ToolRowView extends ToolUsageStats {
|
|
238
|
+
/** errors / calls (0 for zero calls). */
|
|
239
|
+
errorRate: number;
|
|
240
|
+
/** Calls relative to the busiest tool, 0-100, for the share bar. */
|
|
241
|
+
callsPercentage: number;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function buildToolRows(tools: ToolUsageStats[]): ToolRowView[] {
|
|
245
|
+
const maxCalls = tools.reduce((max, t) => Math.max(max, t.calls), 0);
|
|
246
|
+
return tools.map(t => ({
|
|
247
|
+
...t,
|
|
248
|
+
errorRate: t.calls > 0 ? t.errors / t.calls : 0,
|
|
249
|
+
callsPercentage: maxCalls > 0 ? (t.calls / maxCalls) * 100 : 0,
|
|
250
|
+
}));
|
|
251
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import "./styles.css";
|
|
2
|
+
// Side-effect import: registers all Chart.js scales/elements/plugins exactly
|
|
3
|
+
// once before any chart renders. Without it the first chart throws
|
|
4
|
+
// "category is not a registered scale" and unmounts the whole app.
|
|
5
|
+
import "./data/charts";
|
|
6
|
+
import { createRoot } from "react-dom/client";
|
|
7
|
+
import App from "./App";
|
|
8
|
+
|
|
9
|
+
const root = createRoot(document.getElementById("root")!);
|
|
10
|
+
root.render(<App />);
|