@cryptiklemur/lattice 1.7.0 → 1.8.0
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/client/src/components/analytics/AnalyticsView.tsx +37 -0
- package/client/src/components/analytics/charts/PermissionBreakdown.tsx +101 -0
- package/client/src/components/analytics/charts/ProjectRadar.tsx +122 -0
- package/client/src/components/analytics/charts/SessionComplexityList.tsx +67 -0
- package/client/src/components/analytics/charts/ToolSunburst.tsx +126 -0
- package/client/src/components/analytics/charts/ToolTreemap.tsx +108 -0
- package/package.json +1 -1
- package/server/src/analytics/engine.ts +97 -0
- package/shared/src/analytics.ts +6 -0
|
@@ -36,6 +36,11 @@ import { ActivityCalendar } from "./charts/ActivityCalendar";
|
|
|
36
36
|
import { HourlyHeatmap } from "./charts/HourlyHeatmap";
|
|
37
37
|
import { SessionTimeline } from "./charts/SessionTimeline";
|
|
38
38
|
import { DailySummaryCards } from "./charts/DailySummaryCards";
|
|
39
|
+
import { ToolTreemap } from "./charts/ToolTreemap";
|
|
40
|
+
import { ToolSunburst } from "./charts/ToolSunburst";
|
|
41
|
+
import { PermissionBreakdown } from "./charts/PermissionBreakdown";
|
|
42
|
+
import { ProjectRadar } from "./charts/ProjectRadar";
|
|
43
|
+
import { SessionComplexityList } from "./charts/SessionComplexityList";
|
|
39
44
|
|
|
40
45
|
export function AnalyticsView() {
|
|
41
46
|
var analytics = useAnalytics();
|
|
@@ -136,6 +141,38 @@ export function AnalyticsView() {
|
|
|
136
141
|
<DailySummaryCards data={analytics.data.dailySummaries} />
|
|
137
142
|
</ChartErrorBoundary>
|
|
138
143
|
</ChartCard>
|
|
144
|
+
|
|
145
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
146
|
+
<ChartCard title="Tool Usage (Treemap)">
|
|
147
|
+
<ChartErrorBoundary name="Treemap">
|
|
148
|
+
<ToolTreemap data={analytics.data.toolTreemap} />
|
|
149
|
+
</ChartErrorBoundary>
|
|
150
|
+
</ChartCard>
|
|
151
|
+
<ChartCard title="Tool Categories">
|
|
152
|
+
<ChartErrorBoundary name="Sunburst">
|
|
153
|
+
<ToolSunburst data={analytics.data.toolSunburst} />
|
|
154
|
+
</ChartErrorBoundary>
|
|
155
|
+
</ChartCard>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
159
|
+
<ChartCard title="Permissions">
|
|
160
|
+
<ChartErrorBoundary name="Permissions">
|
|
161
|
+
<PermissionBreakdown data={analytics.data.permissionStats} />
|
|
162
|
+
</ChartErrorBoundary>
|
|
163
|
+
</ChartCard>
|
|
164
|
+
<ChartCard title="Project Comparison">
|
|
165
|
+
<ChartErrorBoundary name="Radar">
|
|
166
|
+
<ProjectRadar data={analytics.data.projectRadar} />
|
|
167
|
+
</ChartErrorBoundary>
|
|
168
|
+
</ChartCard>
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<ChartCard title="Session Complexity">
|
|
172
|
+
<ChartErrorBoundary name="Complexity">
|
|
173
|
+
<SessionComplexityList data={analytics.data.sessionComplexity} />
|
|
174
|
+
</ChartErrorBoundary>
|
|
175
|
+
</ChartCard>
|
|
139
176
|
</div>
|
|
140
177
|
)}
|
|
141
178
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts";
|
|
2
|
+
|
|
3
|
+
interface PermissionBreakdownProps {
|
|
4
|
+
data: { allowed: number; denied: number; alwaysAllowed: number };
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
var COLORS = {
|
|
8
|
+
allowed: "#22c55e",
|
|
9
|
+
denied: "#ef4444",
|
|
10
|
+
alwaysAllowed: "oklch(55% 0.25 280)",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ name: string; value: number }> }) {
|
|
14
|
+
if (!active || !payload || payload.length === 0) return null;
|
|
15
|
+
var entry = payload[0];
|
|
16
|
+
return (
|
|
17
|
+
<div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
|
|
18
|
+
<p className="text-[11px] font-mono text-base-content">{entry.name}</p>
|
|
19
|
+
<p className="text-[10px] font-mono text-base-content/60">{entry.value.toLocaleString()}</p>
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function PermissionBreakdown({ data }: PermissionBreakdownProps) {
|
|
25
|
+
var total = data.allowed + data.denied + data.alwaysAllowed;
|
|
26
|
+
|
|
27
|
+
if (total === 0) {
|
|
28
|
+
return (
|
|
29
|
+
<div className="flex items-center justify-center h-[200px] text-base-content/25 font-mono text-[11px]">
|
|
30
|
+
No permission data
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (data.denied === 0 && data.alwaysAllowed === 0) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="flex flex-col items-center justify-center h-[200px] gap-2">
|
|
38
|
+
<div className="text-[32px] font-mono font-bold text-base-content/80">{total.toLocaleString()}</div>
|
|
39
|
+
<div className="text-[11px] font-mono text-base-content/40">tool calls — all allowed</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
var pieData = [
|
|
45
|
+
{ name: "Allowed", value: data.allowed },
|
|
46
|
+
{ name: "Denied", value: data.denied },
|
|
47
|
+
{ name: "Always Allowed", value: data.alwaysAllowed },
|
|
48
|
+
].filter(function (d) { return d.value > 0; });
|
|
49
|
+
|
|
50
|
+
var colorMap: Record<string, string> = {
|
|
51
|
+
Allowed: COLORS.allowed,
|
|
52
|
+
Denied: COLORS.denied,
|
|
53
|
+
"Always Allowed": COLORS.alwaysAllowed,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<div>
|
|
58
|
+
<ResponsiveContainer width="100%" height={180}>
|
|
59
|
+
<PieChart>
|
|
60
|
+
<Pie
|
|
61
|
+
data={pieData}
|
|
62
|
+
dataKey="value"
|
|
63
|
+
nameKey="name"
|
|
64
|
+
innerRadius={45}
|
|
65
|
+
outerRadius={70}
|
|
66
|
+
paddingAngle={2}
|
|
67
|
+
startAngle={90}
|
|
68
|
+
endAngle={-270}
|
|
69
|
+
>
|
|
70
|
+
{pieData.map(function (entry) {
|
|
71
|
+
return <Cell key={entry.name} fill={colorMap[entry.name] || "#888"} />;
|
|
72
|
+
})}
|
|
73
|
+
</Pie>
|
|
74
|
+
<Tooltip content={<CustomTooltip />} />
|
|
75
|
+
<text x="50%" y="50%" textAnchor="middle" dominantBaseline="middle">
|
|
76
|
+
<tspan x="50%" dy="-0.3em" style={{ fontSize: 14, fontFamily: "var(--font-mono)", fill: "oklch(0.9 0.02 280 / 0.9)", fontWeight: 700 }}>
|
|
77
|
+
{total.toLocaleString()}
|
|
78
|
+
</tspan>
|
|
79
|
+
<tspan x="50%" dy="1.3em" style={{ fontSize: 9, fontFamily: "var(--font-mono)", fill: "oklch(0.9 0.02 280 / 0.35)" }}>
|
|
80
|
+
total
|
|
81
|
+
</tspan>
|
|
82
|
+
</text>
|
|
83
|
+
</PieChart>
|
|
84
|
+
</ResponsiveContainer>
|
|
85
|
+
<div className="flex flex-wrap justify-center gap-3 mt-1">
|
|
86
|
+
{pieData.map(function (entry) {
|
|
87
|
+
return (
|
|
88
|
+
<div key={entry.name} className="flex items-center gap-1.5 text-[10px] font-mono text-base-content/50">
|
|
89
|
+
<span
|
|
90
|
+
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
|
91
|
+
style={{ background: colorMap[entry.name] }}
|
|
92
|
+
/>
|
|
93
|
+
<span>{entry.name}</span>
|
|
94
|
+
<span className="text-base-content/30">{entry.value.toLocaleString()}</span>
|
|
95
|
+
</div>
|
|
96
|
+
);
|
|
97
|
+
})}
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import {
|
|
2
|
+
RadarChart,
|
|
3
|
+
Radar,
|
|
4
|
+
PolarGrid,
|
|
5
|
+
PolarAngleAxis,
|
|
6
|
+
PolarRadiusAxis,
|
|
7
|
+
Tooltip,
|
|
8
|
+
ResponsiveContainer,
|
|
9
|
+
Legend,
|
|
10
|
+
} from "recharts";
|
|
11
|
+
|
|
12
|
+
interface ProjectRadarProps {
|
|
13
|
+
data: Array<{ project: string; cost: number; sessions: number; avgDuration: number; toolDiversity: number; tokensPerSession: number }>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
var PROJECT_COLORS = ["#a855f7", "#22c55e", "#f59e0b", "#ef4444", "oklch(55% 0.25 280)"];
|
|
17
|
+
|
|
18
|
+
var AXIS_KEYS = ["cost", "sessions", "avgDuration", "toolDiversity", "tokensPerSession"] as const;
|
|
19
|
+
var AXIS_LABELS: Record<string, string> = {
|
|
20
|
+
cost: "Cost",
|
|
21
|
+
sessions: "Sessions",
|
|
22
|
+
avgDuration: "Duration",
|
|
23
|
+
toolDiversity: "Tool Diversity",
|
|
24
|
+
tokensPerSession: "Tokens/Session",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function normalize(values: number[]): number[] {
|
|
28
|
+
var max = 0;
|
|
29
|
+
for (var i = 0; i < values.length; i++) {
|
|
30
|
+
if (values[i] > max) max = values[i];
|
|
31
|
+
}
|
|
32
|
+
if (max === 0) return values.map(function () { return 0; });
|
|
33
|
+
return values.map(function (v) { return Math.round((v / max) * 100); });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function CustomTooltip({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) {
|
|
37
|
+
if (!active || !payload || payload.length === 0) return null;
|
|
38
|
+
return (
|
|
39
|
+
<div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
|
|
40
|
+
<p className="text-[10px] font-mono text-base-content/50 mb-1">{label}</p>
|
|
41
|
+
{payload.map(function (entry) {
|
|
42
|
+
return (
|
|
43
|
+
<p key={entry.name} className="text-[10px] font-mono text-base-content/70">
|
|
44
|
+
<span style={{ color: entry.color }}>{entry.name}</span>: {entry.value}
|
|
45
|
+
</p>
|
|
46
|
+
);
|
|
47
|
+
})}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function ProjectRadar({ data }: ProjectRadarProps) {
|
|
53
|
+
if (!data || data.length === 0) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="flex items-center justify-center h-[250px] text-base-content/25 font-mono text-[11px]">
|
|
56
|
+
No project data
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
var projects = data.slice(0, 5);
|
|
62
|
+
|
|
63
|
+
var normalized = new Map<string, Map<string, number>>();
|
|
64
|
+
for (var ai = 0; ai < AXIS_KEYS.length; ai++) {
|
|
65
|
+
var key = AXIS_KEYS[ai];
|
|
66
|
+
var rawValues = projects.map(function (p) { return p[key] as number; });
|
|
67
|
+
var normValues = normalize(rawValues);
|
|
68
|
+
for (var pi = 0; pi < projects.length; pi++) {
|
|
69
|
+
var projMap = normalized.get(projects[pi].project);
|
|
70
|
+
if (!projMap) {
|
|
71
|
+
projMap = new Map();
|
|
72
|
+
normalized.set(projects[pi].project, projMap);
|
|
73
|
+
}
|
|
74
|
+
projMap.set(key, normValues[pi]);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
var radarData = AXIS_KEYS.map(function (key) {
|
|
79
|
+
var entry: Record<string, string | number> = { axis: AXIS_LABELS[key] };
|
|
80
|
+
for (var pi = 0; pi < projects.length; pi++) {
|
|
81
|
+
var projMap = normalized.get(projects[pi].project);
|
|
82
|
+
entry[projects[pi].project] = projMap ? projMap.get(key) || 0 : 0;
|
|
83
|
+
}
|
|
84
|
+
return entry;
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<ResponsiveContainer width="100%" height={280}>
|
|
89
|
+
<RadarChart data={radarData} margin={{ top: 10, right: 30, bottom: 10, left: 30 }}>
|
|
90
|
+
<PolarGrid stroke="oklch(0.9 0.02 280 / 0.08)" />
|
|
91
|
+
<PolarAngleAxis
|
|
92
|
+
dataKey="axis"
|
|
93
|
+
tick={{ fontSize: 9, fontFamily: "var(--font-mono)", fill: "oklch(0.9 0.02 280 / 0.4)" }}
|
|
94
|
+
/>
|
|
95
|
+
<PolarRadiusAxis
|
|
96
|
+
angle={90}
|
|
97
|
+
domain={[0, 100]}
|
|
98
|
+
tick={false}
|
|
99
|
+
axisLine={false}
|
|
100
|
+
/>
|
|
101
|
+
{projects.map(function (project, index) {
|
|
102
|
+
return (
|
|
103
|
+
<Radar
|
|
104
|
+
key={project.project}
|
|
105
|
+
name={project.project}
|
|
106
|
+
dataKey={project.project}
|
|
107
|
+
stroke={PROJECT_COLORS[index % PROJECT_COLORS.length]}
|
|
108
|
+
fill={PROJECT_COLORS[index % PROJECT_COLORS.length]}
|
|
109
|
+
fillOpacity={0.1}
|
|
110
|
+
strokeWidth={1.5}
|
|
111
|
+
/>
|
|
112
|
+
);
|
|
113
|
+
})}
|
|
114
|
+
<Tooltip content={<CustomTooltip />} />
|
|
115
|
+
<Legend
|
|
116
|
+
wrapperStyle={{ fontSize: 10, fontFamily: "var(--font-mono)" }}
|
|
117
|
+
iconSize={8}
|
|
118
|
+
/>
|
|
119
|
+
</RadarChart>
|
|
120
|
+
</ResponsiveContainer>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
interface SessionComplexityListProps {
|
|
2
|
+
data: Array<{ id: string; title: string; score: number; messages: number; tools: number; contextPercent: number }>;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
function getScoreColor(score: number, maxScore: number): string {
|
|
6
|
+
var intensity = maxScore > 0 ? Math.min(score / maxScore, 1) : 0;
|
|
7
|
+
var lightness = 0.45 - intensity * 0.1;
|
|
8
|
+
var chroma = 0.1 + intensity * 0.18;
|
|
9
|
+
return "oklch(" + lightness + " " + chroma + " 280)";
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function SessionComplexityList({ data }: SessionComplexityListProps) {
|
|
13
|
+
if (!data || data.length === 0) {
|
|
14
|
+
return (
|
|
15
|
+
<div className="flex items-center justify-center h-[200px] text-base-content/25 font-mono text-[11px]">
|
|
16
|
+
No session data
|
|
17
|
+
</div>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
var maxScore = data.length > 0 ? data[0].score : 1;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="flex flex-col gap-1 max-h-[400px] overflow-y-auto">
|
|
25
|
+
<div className="grid grid-cols-[2.5rem_1fr_4rem_3rem_3rem_3.5rem] gap-2 px-2 py-1 text-[9px] font-mono text-base-content/30 uppercase tracking-wider">
|
|
26
|
+
<span>Rank</span>
|
|
27
|
+
<span>Session</span>
|
|
28
|
+
<span className="text-right">Score</span>
|
|
29
|
+
<span className="text-right">Msgs</span>
|
|
30
|
+
<span className="text-right">Tools</span>
|
|
31
|
+
<span className="text-right">Ctx%</span>
|
|
32
|
+
</div>
|
|
33
|
+
{data.map(function (entry, index) {
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
key={entry.id}
|
|
37
|
+
className="grid grid-cols-[2.5rem_1fr_4rem_3rem_3rem_3.5rem] gap-2 px-2 py-1.5 rounded hover:bg-base-content/[0.03] transition-colors"
|
|
38
|
+
>
|
|
39
|
+
<span className="font-mono text-[11px] text-base-content/40 font-bold tabular-nums">
|
|
40
|
+
#{index + 1}
|
|
41
|
+
</span>
|
|
42
|
+
<span className="font-mono text-[11px] text-base-content/70 truncate" title={entry.title}>
|
|
43
|
+
{entry.title}
|
|
44
|
+
</span>
|
|
45
|
+
<span className="text-right">
|
|
46
|
+
<span
|
|
47
|
+
className="inline-block px-1.5 py-0.5 rounded text-[10px] font-mono font-bold text-white/90 tabular-nums"
|
|
48
|
+
style={{ background: getScoreColor(entry.score, maxScore) }}
|
|
49
|
+
>
|
|
50
|
+
{entry.score}
|
|
51
|
+
</span>
|
|
52
|
+
</span>
|
|
53
|
+
<span className="text-right font-mono text-[10px] text-base-content/50 tabular-nums self-center">
|
|
54
|
+
{entry.messages}
|
|
55
|
+
</span>
|
|
56
|
+
<span className="text-right font-mono text-[10px] text-base-content/50 tabular-nums self-center">
|
|
57
|
+
{entry.tools}
|
|
58
|
+
</span>
|
|
59
|
+
<span className="text-right font-mono text-[10px] text-base-content/50 tabular-nums self-center">
|
|
60
|
+
{entry.contextPercent}%
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
63
|
+
);
|
|
64
|
+
})}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer } from "recharts";
|
|
2
|
+
|
|
3
|
+
interface ToolSunburstProps {
|
|
4
|
+
data: Array<{ name: string; category: string; count: number }>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
var CATEGORY_COLORS: Record<string, string> = {
|
|
8
|
+
Read: "#22c55e",
|
|
9
|
+
Write: "#f59e0b",
|
|
10
|
+
Execute: "#ef4444",
|
|
11
|
+
AI: "#a855f7",
|
|
12
|
+
Other: "oklch(55% 0.15 280)",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function getCategoryColor(category: string): string {
|
|
16
|
+
return CATEGORY_COLORS[category] || CATEGORY_COLORS.Other;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getToolColor(category: string, index: number): string {
|
|
20
|
+
var base = getCategoryColor(category);
|
|
21
|
+
var opacity = 0.9 - index * 0.12;
|
|
22
|
+
if (opacity < 0.4) opacity = 0.4;
|
|
23
|
+
return base.startsWith("oklch") ? base.replace(")", " / " + opacity + ")") : base + String(Math.round(opacity * 255).toString(16)).padStart(2, "0");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ name: string; value: number; payload: { category?: string } }> }) {
|
|
27
|
+
if (!active || !payload || payload.length === 0) return null;
|
|
28
|
+
var entry = payload[0];
|
|
29
|
+
return (
|
|
30
|
+
<div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
|
|
31
|
+
<p className="text-[11px] font-mono text-base-content font-bold">{entry.name}</p>
|
|
32
|
+
{entry.payload.category && (
|
|
33
|
+
<p className="text-[10px] font-mono text-base-content/40">{entry.payload.category}</p>
|
|
34
|
+
)}
|
|
35
|
+
<p className="text-[10px] font-mono text-base-content/60">{entry.value.toLocaleString()} calls</p>
|
|
36
|
+
</div>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ToolSunburst({ data }: ToolSunburstProps) {
|
|
41
|
+
if (!data || data.length === 0) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex items-center justify-center h-[250px] text-base-content/25 font-mono text-[11px]">
|
|
44
|
+
No tool data
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
var categoryTotals = new Map<string, number>();
|
|
50
|
+
for (var i = 0; i < data.length; i++) {
|
|
51
|
+
var cat = data[i].category;
|
|
52
|
+
categoryTotals.set(cat, (categoryTotals.get(cat) || 0) + data[i].count);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
var innerData: Array<{ name: string; value: number }> = [];
|
|
56
|
+
categoryTotals.forEach(function (count, category) {
|
|
57
|
+
innerData.push({ name: category, value: count });
|
|
58
|
+
});
|
|
59
|
+
innerData.sort(function (a, b) { return b.value - a.value; });
|
|
60
|
+
|
|
61
|
+
var categoryOrder: string[] = innerData.map(function (d) { return d.name; });
|
|
62
|
+
|
|
63
|
+
var outerData: Array<{ name: string; category: string; value: number }> = [];
|
|
64
|
+
for (var ci = 0; ci < categoryOrder.length; ci++) {
|
|
65
|
+
var catName = categoryOrder[ci];
|
|
66
|
+
var catTools = data
|
|
67
|
+
.filter(function (d) { return d.category === catName; })
|
|
68
|
+
.sort(function (a, b) { return b.count - a.count; });
|
|
69
|
+
for (var ti = 0; ti < catTools.length; ti++) {
|
|
70
|
+
outerData.push({ name: catTools[ti].name, category: catName, value: catTools[ti].count });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div>
|
|
76
|
+
<ResponsiveContainer width="100%" height={220}>
|
|
77
|
+
<PieChart>
|
|
78
|
+
<Pie
|
|
79
|
+
data={innerData}
|
|
80
|
+
dataKey="value"
|
|
81
|
+
nameKey="name"
|
|
82
|
+
innerRadius={35}
|
|
83
|
+
outerRadius={60}
|
|
84
|
+
paddingAngle={2}
|
|
85
|
+
startAngle={90}
|
|
86
|
+
endAngle={-270}
|
|
87
|
+
>
|
|
88
|
+
{innerData.map(function (entry) {
|
|
89
|
+
return <Cell key={"inner-" + entry.name} fill={getCategoryColor(entry.name)} />;
|
|
90
|
+
})}
|
|
91
|
+
</Pie>
|
|
92
|
+
<Pie
|
|
93
|
+
data={outerData}
|
|
94
|
+
dataKey="value"
|
|
95
|
+
nameKey="name"
|
|
96
|
+
innerRadius={65}
|
|
97
|
+
outerRadius={90}
|
|
98
|
+
paddingAngle={1}
|
|
99
|
+
startAngle={90}
|
|
100
|
+
endAngle={-270}
|
|
101
|
+
>
|
|
102
|
+
{outerData.map(function (entry, index) {
|
|
103
|
+
var catIndex = outerData.slice(0, index).filter(function (d) { return d.category === entry.category; }).length;
|
|
104
|
+
return <Cell key={"outer-" + entry.name} fill={getToolColor(entry.category, catIndex)} />;
|
|
105
|
+
})}
|
|
106
|
+
</Pie>
|
|
107
|
+
<Tooltip content={<CustomTooltip />} />
|
|
108
|
+
</PieChart>
|
|
109
|
+
</ResponsiveContainer>
|
|
110
|
+
<div className="flex flex-wrap justify-center gap-3 mt-1">
|
|
111
|
+
{innerData.map(function (entry) {
|
|
112
|
+
return (
|
|
113
|
+
<div key={entry.name} className="flex items-center gap-1.5 text-[10px] font-mono text-base-content/50">
|
|
114
|
+
<span
|
|
115
|
+
className="inline-block w-2 h-2 rounded-full flex-shrink-0"
|
|
116
|
+
style={{ background: getCategoryColor(entry.name) }}
|
|
117
|
+
/>
|
|
118
|
+
<span>{entry.name}</span>
|
|
119
|
+
<span className="text-base-content/30">{entry.value}</span>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
})}
|
|
123
|
+
</div>
|
|
124
|
+
</div>
|
|
125
|
+
);
|
|
126
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { Treemap, ResponsiveContainer, Tooltip } from "recharts";
|
|
2
|
+
|
|
3
|
+
interface ToolTreemapProps {
|
|
4
|
+
data: Array<{ name: string; count: number; avgCost: number }>;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
var MAX_COST_INTENSITY = 0.5;
|
|
8
|
+
|
|
9
|
+
function getColor(avgCost: number, maxCost: number): string {
|
|
10
|
+
var intensity = maxCost > 0 ? Math.min(avgCost / maxCost, 1) : 0.3;
|
|
11
|
+
var lightness = 0.45 - intensity * 0.15;
|
|
12
|
+
var chroma = 0.15 + intensity * 0.12;
|
|
13
|
+
return "oklch(" + lightness + " " + chroma + " 280)";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function CustomContent(props: {
|
|
17
|
+
x?: number; y?: number; width?: number; height?: number;
|
|
18
|
+
name?: string; count?: number; avgCost?: number;
|
|
19
|
+
maxCost?: number;
|
|
20
|
+
}) {
|
|
21
|
+
var { x = 0, y = 0, width = 0, height = 0, name = "", count = 0, avgCost = 0, maxCost = MAX_COST_INTENSITY } = props;
|
|
22
|
+
if (width < 4 || height < 4) return null;
|
|
23
|
+
var showLabel = width > 40 && height > 20;
|
|
24
|
+
var showCount = width > 50 && height > 34;
|
|
25
|
+
return (
|
|
26
|
+
<g>
|
|
27
|
+
<rect
|
|
28
|
+
x={x + 1}
|
|
29
|
+
y={y + 1}
|
|
30
|
+
width={width - 2}
|
|
31
|
+
height={height - 2}
|
|
32
|
+
rx={3}
|
|
33
|
+
fill={getColor(avgCost, maxCost)}
|
|
34
|
+
fillOpacity={0.85}
|
|
35
|
+
stroke="oklch(0.2 0.02 280)"
|
|
36
|
+
strokeWidth={1}
|
|
37
|
+
/>
|
|
38
|
+
{showLabel && (
|
|
39
|
+
<text
|
|
40
|
+
x={x + width / 2}
|
|
41
|
+
y={y + height / 2 + (showCount ? -5 : 0)}
|
|
42
|
+
textAnchor="middle"
|
|
43
|
+
dominantBaseline="middle"
|
|
44
|
+
style={{ fontSize: 10, fontFamily: "var(--font-mono)", fill: "oklch(0.95 0.02 280 / 0.9)" }}
|
|
45
|
+
>
|
|
46
|
+
{name}
|
|
47
|
+
</text>
|
|
48
|
+
)}
|
|
49
|
+
{showCount && (
|
|
50
|
+
<text
|
|
51
|
+
x={x + width / 2}
|
|
52
|
+
y={y + height / 2 + 9}
|
|
53
|
+
textAnchor="middle"
|
|
54
|
+
dominantBaseline="middle"
|
|
55
|
+
style={{ fontSize: 9, fontFamily: "var(--font-mono)", fill: "oklch(0.95 0.02 280 / 0.5)" }}
|
|
56
|
+
>
|
|
57
|
+
{count}
|
|
58
|
+
</text>
|
|
59
|
+
)}
|
|
60
|
+
</g>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function CustomTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: { name: string; count: number; avgCost: number } }> }) {
|
|
65
|
+
if (!active || !payload || payload.length === 0) return null;
|
|
66
|
+
var d = payload[0].payload;
|
|
67
|
+
return (
|
|
68
|
+
<div className="rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg">
|
|
69
|
+
<p className="text-[11px] font-mono text-base-content font-bold mb-1">{d.name}</p>
|
|
70
|
+
<div className="text-[10px] font-mono text-base-content/60 space-y-0.5">
|
|
71
|
+
<p><span className="text-base-content/40">calls </span>{d.count.toLocaleString()}</p>
|
|
72
|
+
<p><span className="text-base-content/40">avg cost </span>${d.avgCost.toFixed(4)}</p>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function ToolTreemap({ data }: ToolTreemapProps) {
|
|
79
|
+
if (!data || data.length === 0) {
|
|
80
|
+
return (
|
|
81
|
+
<div className="flex items-center justify-center h-[250px] text-base-content/25 font-mono text-[11px]">
|
|
82
|
+
No tool data
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
var maxCost = 0;
|
|
88
|
+
for (var i = 0; i < data.length; i++) {
|
|
89
|
+
if (data[i].avgCost > maxCost) maxCost = data[i].avgCost;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
var treemapData = data.map(function (d) {
|
|
93
|
+
return { name: d.name, size: d.count, count: d.count, avgCost: d.avgCost, maxCost: maxCost };
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<ResponsiveContainer width="100%" height={250}>
|
|
98
|
+
<Treemap
|
|
99
|
+
data={treemapData}
|
|
100
|
+
dataKey="size"
|
|
101
|
+
aspectRatio={4 / 3}
|
|
102
|
+
content={<CustomContent maxCost={maxCost} />}
|
|
103
|
+
>
|
|
104
|
+
<Tooltip content={<CustomTooltip />} />
|
|
105
|
+
</Treemap>
|
|
106
|
+
</ResponsiveContainer>
|
|
107
|
+
);
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cryptiklemur/lattice",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Multi-machine agentic dashboard for Claude Code. Monitor sessions, manage MCP servers and skills, orchestrate across mesh-networked nodes.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Aaron Scherer <me@aaronscherer.me>",
|
|
@@ -638,6 +638,98 @@ function aggregate(sessions: SessionData[], period: AnalyticsPeriod): AnalyticsP
|
|
|
638
638
|
});
|
|
639
639
|
}
|
|
640
640
|
|
|
641
|
+
var toolTreemap: AnalyticsPayload["toolTreemap"] = [];
|
|
642
|
+
toolStats.forEach(function (val, key) {
|
|
643
|
+
toolTreemap.push({
|
|
644
|
+
name: key,
|
|
645
|
+
count: val.count,
|
|
646
|
+
avgCost: val.sessions > 0 ? val.totalCost / val.sessions : 0,
|
|
647
|
+
});
|
|
648
|
+
});
|
|
649
|
+
toolTreemap.sort(function (a, b) { return b.count - a.count; });
|
|
650
|
+
|
|
651
|
+
var toolCategoryMap: Record<string, string> = {
|
|
652
|
+
Read: "Read", Glob: "Read", Grep: "Read", LS: "Read",
|
|
653
|
+
Edit: "Write", Write: "Write", MultiEdit: "Write",
|
|
654
|
+
Bash: "Execute",
|
|
655
|
+
Agent: "AI", Skill: "AI",
|
|
656
|
+
};
|
|
657
|
+
var toolSunburst: AnalyticsPayload["toolSunburst"] = [];
|
|
658
|
+
toolStats.forEach(function (val, key) {
|
|
659
|
+
var category = toolCategoryMap[key] || "Other";
|
|
660
|
+
toolSunburst.push({ name: key, category: category, count: val.count });
|
|
661
|
+
});
|
|
662
|
+
toolSunburst.sort(function (a, b) { return b.count - a.count; });
|
|
663
|
+
|
|
664
|
+
var totalToolCalls = 0;
|
|
665
|
+
toolStats.forEach(function (val) { totalToolCalls += val.count; });
|
|
666
|
+
var permissionStats: AnalyticsPayload["permissionStats"] = {
|
|
667
|
+
allowed: totalToolCalls,
|
|
668
|
+
denied: 0,
|
|
669
|
+
alwaysAllowed: 0,
|
|
670
|
+
};
|
|
671
|
+
|
|
672
|
+
var projectRadarMap = new Map<string, { cost: number; sessions: number; totalDuration: number; durationCount: number; tools: Set<string>; totalTokens: number }>();
|
|
673
|
+
for (var pri = 0; pri < filtered.length; pri++) {
|
|
674
|
+
var prSess = filtered[pri];
|
|
675
|
+
var prEntry = projectRadarMap.get(prSess.project);
|
|
676
|
+
if (!prEntry) {
|
|
677
|
+
prEntry = { cost: 0, sessions: 0, totalDuration: 0, durationCount: 0, tools: new Set(), totalTokens: 0 };
|
|
678
|
+
projectRadarMap.set(prSess.project, prEntry);
|
|
679
|
+
}
|
|
680
|
+
prEntry.cost += prSess.cost;
|
|
681
|
+
prEntry.sessions++;
|
|
682
|
+
prEntry.totalTokens += prSess.inputTokens + prSess.outputTokens;
|
|
683
|
+
if (prSess.startTime > 0 && prSess.endTime > prSess.startTime) {
|
|
684
|
+
prEntry.totalDuration += prSess.endTime - prSess.startTime;
|
|
685
|
+
prEntry.durationCount++;
|
|
686
|
+
}
|
|
687
|
+
prSess.tools.forEach(function (_count, tool) { prEntry!.tools.add(tool); });
|
|
688
|
+
}
|
|
689
|
+
var projectRadar: AnalyticsPayload["projectRadar"] = [];
|
|
690
|
+
projectRadarMap.forEach(function (val, key) {
|
|
691
|
+
projectRadar.push({
|
|
692
|
+
project: key,
|
|
693
|
+
cost: val.cost,
|
|
694
|
+
sessions: val.sessions,
|
|
695
|
+
avgDuration: val.durationCount > 0 ? val.totalDuration / val.durationCount : 0,
|
|
696
|
+
toolDiversity: val.tools.size,
|
|
697
|
+
tokensPerSession: val.sessions > 0 ? val.totalTokens / val.sessions : 0,
|
|
698
|
+
});
|
|
699
|
+
});
|
|
700
|
+
projectRadar.sort(function (a, b) { return b.cost - a.cost; });
|
|
701
|
+
if (projectRadar.length > 5) projectRadar.length = 5;
|
|
702
|
+
|
|
703
|
+
var contextWindowSizesForComplexity: Record<string, number> = { opus: 200000, sonnet: 200000, haiku: 200000, other: 200000 };
|
|
704
|
+
var sessionComplexity: AnalyticsPayload["sessionComplexity"] = [];
|
|
705
|
+
for (var sci = 0; sci < filtered.length; sci++) {
|
|
706
|
+
var scSess = filtered[sci];
|
|
707
|
+
var scUniqueTools = scSess.tools.size;
|
|
708
|
+
var scMessages = scSess.contextMessages.length;
|
|
709
|
+
var scRunning = 0;
|
|
710
|
+
var scPrimaryModel = "other";
|
|
711
|
+
var scMaxTokens = 0;
|
|
712
|
+
scSess.models.forEach(function (val, key) {
|
|
713
|
+
if (val.tokens > scMaxTokens) { scMaxTokens = val.tokens; scPrimaryModel = key; }
|
|
714
|
+
});
|
|
715
|
+
var scWindowSize = contextWindowSizesForComplexity[scPrimaryModel] || 200000;
|
|
716
|
+
for (var scmi = 0; scmi < scSess.contextMessages.length; scmi++) {
|
|
717
|
+
scRunning += scSess.contextMessages[scmi].inputTokens;
|
|
718
|
+
}
|
|
719
|
+
var scContextPercent = Math.min((scRunning / scWindowSize) * 100, 100);
|
|
720
|
+
var scScore = (scMessages * 1) + (scUniqueTools * 5) + (scContextPercent * 0.5);
|
|
721
|
+
sessionComplexity.push({
|
|
722
|
+
id: scSess.id,
|
|
723
|
+
title: scSess.title,
|
|
724
|
+
score: Math.round(scScore * 10) / 10,
|
|
725
|
+
messages: scMessages,
|
|
726
|
+
tools: scUniqueTools,
|
|
727
|
+
contextPercent: Math.round(scContextPercent * 10) / 10,
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
sessionComplexity.sort(function (a, b) { return b.score - a.score; });
|
|
731
|
+
if (sessionComplexity.length > 20) sessionComplexity.length = 20;
|
|
732
|
+
|
|
641
733
|
return {
|
|
642
734
|
totalCost: totalCost,
|
|
643
735
|
totalSessions: filtered.length,
|
|
@@ -667,6 +759,11 @@ function aggregate(sessions: SessionData[], period: AnalyticsPeriod): AnalyticsP
|
|
|
667
759
|
hourlyHeatmap: hourlyHeatmap,
|
|
668
760
|
sessionTimeline: sessionTimeline,
|
|
669
761
|
dailySummaries: dailySummaries,
|
|
762
|
+
toolTreemap: toolTreemap,
|
|
763
|
+
toolSunburst: toolSunburst,
|
|
764
|
+
permissionStats: permissionStats,
|
|
765
|
+
projectRadar: projectRadar,
|
|
766
|
+
sessionComplexity: sessionComplexity,
|
|
670
767
|
};
|
|
671
768
|
}
|
|
672
769
|
|
package/shared/src/analytics.ts
CHANGED
|
@@ -27,6 +27,12 @@ export interface AnalyticsPayload {
|
|
|
27
27
|
hourlyHeatmap: Array<{ day: number; hour: number; count: number }>;
|
|
28
28
|
sessionTimeline: Array<{ id: string; title: string; project: string; start: number; end: number; cost: number }>;
|
|
29
29
|
dailySummaries: Array<{ date: string; sessions: number; cost: number; tokens: number; topTool: string; modelMix: Record<string, number> }>;
|
|
30
|
+
|
|
31
|
+
toolTreemap: Array<{ name: string; count: number; avgCost: number }>;
|
|
32
|
+
toolSunburst: Array<{ name: string; category: string; count: number }>;
|
|
33
|
+
permissionStats: { allowed: number; denied: number; alwaysAllowed: number };
|
|
34
|
+
projectRadar: Array<{ project: string; cost: number; sessions: number; avgDuration: number; toolDiversity: number; tokensPerSession: number }>;
|
|
35
|
+
sessionComplexity: Array<{ id: string; title: string; score: number; messages: number; tools: number; contextPercent: number }>;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export type AnalyticsPeriod = "24h" | "7d" | "30d" | "90d" | "all";
|