@cryptiklemur/lattice 1.6.0 → 1.7.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 +29 -0
- package/client/src/components/analytics/charts/ActivityCalendar.tsx +185 -0
- package/client/src/components/analytics/charts/DailySummaryCards.tsx +83 -0
- package/client/src/components/analytics/charts/HourlyHeatmap.tsx +129 -0
- package/client/src/components/analytics/charts/SessionTimeline.tsx +112 -0
- package/package.json +1 -1
- package/server/src/analytics/engine.ts +122 -0
- package/shared/src/analytics.ts +5 -0
|
@@ -32,6 +32,10 @@ import { CacheEfficiencyChart } from "./charts/CacheEfficiencyChart";
|
|
|
32
32
|
import { ResponseTimeScatter } from "./charts/ResponseTimeScatter";
|
|
33
33
|
import { ContextUtilizationChart } from "./charts/ContextUtilizationChart";
|
|
34
34
|
import { TokenSankeyChart } from "./charts/TokenSankeyChart";
|
|
35
|
+
import { ActivityCalendar } from "./charts/ActivityCalendar";
|
|
36
|
+
import { HourlyHeatmap } from "./charts/HourlyHeatmap";
|
|
37
|
+
import { SessionTimeline } from "./charts/SessionTimeline";
|
|
38
|
+
import { DailySummaryCards } from "./charts/DailySummaryCards";
|
|
35
39
|
|
|
36
40
|
export function AnalyticsView() {
|
|
37
41
|
var analytics = useAnalytics();
|
|
@@ -107,6 +111,31 @@ export function AnalyticsView() {
|
|
|
107
111
|
</ChartErrorBoundary>
|
|
108
112
|
</ChartCard>
|
|
109
113
|
</div>
|
|
114
|
+
|
|
115
|
+
<ChartCard title="Activity Calendar">
|
|
116
|
+
<ChartErrorBoundary name="Calendar">
|
|
117
|
+
<ActivityCalendar data={analytics.data.activityCalendar} />
|
|
118
|
+
</ChartErrorBoundary>
|
|
119
|
+
</ChartCard>
|
|
120
|
+
|
|
121
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
122
|
+
<ChartCard title="Hourly Activity">
|
|
123
|
+
<ChartErrorBoundary name="HourlyHeatmap">
|
|
124
|
+
<HourlyHeatmap data={analytics.data.hourlyHeatmap} />
|
|
125
|
+
</ChartErrorBoundary>
|
|
126
|
+
</ChartCard>
|
|
127
|
+
<ChartCard title="Session Timeline">
|
|
128
|
+
<ChartErrorBoundary name="Timeline">
|
|
129
|
+
<SessionTimeline data={analytics.data.sessionTimeline} />
|
|
130
|
+
</ChartErrorBoundary>
|
|
131
|
+
</ChartCard>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<ChartCard title="Daily Summary">
|
|
135
|
+
<ChartErrorBoundary name="DailySummary">
|
|
136
|
+
<DailySummaryCards data={analytics.data.dailySummaries} />
|
|
137
|
+
</ChartErrorBoundary>
|
|
138
|
+
</ChartCard>
|
|
110
139
|
</div>
|
|
111
140
|
)}
|
|
112
141
|
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface CalendarDatum {
|
|
4
|
+
date: string;
|
|
5
|
+
count: number;
|
|
6
|
+
tokens: number;
|
|
7
|
+
cost: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ActivityCalendarProps {
|
|
11
|
+
data: CalendarDatum[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
var CELL_SIZE = 11;
|
|
15
|
+
var CELL_GAP = 2;
|
|
16
|
+
var CELL_STEP = CELL_SIZE + CELL_GAP;
|
|
17
|
+
var DAY_LABEL_WIDTH = 28;
|
|
18
|
+
var MONTH_LABEL_HEIGHT = 14;
|
|
19
|
+
|
|
20
|
+
var INTENSITY_OPACITIES = [0.05, 0.15, 0.3, 0.5, 0.8];
|
|
21
|
+
var PRIMARY_COLOR = "oklch(55% 0.25 280)";
|
|
22
|
+
|
|
23
|
+
var MONTH_NAMES = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
24
|
+
var DAY_LABELS = [
|
|
25
|
+
{ index: 1, label: "Mon" },
|
|
26
|
+
{ index: 3, label: "Wed" },
|
|
27
|
+
{ index: 5, label: "Fri" },
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
function getIntensity(count: number, maxCount: number): number {
|
|
31
|
+
if (count === 0 || maxCount === 0) return -1;
|
|
32
|
+
var ratio = count / maxCount;
|
|
33
|
+
if (ratio <= 0.2) return 0;
|
|
34
|
+
if (ratio <= 0.4) return 1;
|
|
35
|
+
if (ratio <= 0.6) return 2;
|
|
36
|
+
if (ratio <= 0.8) return 3;
|
|
37
|
+
return 4;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseDateParts(dateStr: string): { year: number; month: number; day: number } {
|
|
41
|
+
var parts = dateStr.split("-");
|
|
42
|
+
return { year: parseInt(parts[0], 10), month: parseInt(parts[1], 10) - 1, day: parseInt(parts[2], 10) };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ActivityCalendar({ data }: ActivityCalendarProps) {
|
|
46
|
+
var [hover, setHover] = useState<{ x: number; y: number; datum: CalendarDatum } | null>(null);
|
|
47
|
+
|
|
48
|
+
if (!data || data.length === 0) {
|
|
49
|
+
return (
|
|
50
|
+
<div className="flex items-center justify-center h-[120px] text-base-content/25 font-mono text-[11px]">
|
|
51
|
+
No data
|
|
52
|
+
</div>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
var dataMap = new Map<string, CalendarDatum>();
|
|
57
|
+
var maxCount = 0;
|
|
58
|
+
for (var i = 0; i < data.length; i++) {
|
|
59
|
+
dataMap.set(data[i].date, data[i]);
|
|
60
|
+
if (data[i].count > maxCount) maxCount = data[i].count;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
var lastDate = new Date(data[data.length - 1].date + "T00:00:00");
|
|
64
|
+
var startDate = new Date(lastDate);
|
|
65
|
+
startDate.setDate(startDate.getDate() - 364);
|
|
66
|
+
var startDay = startDate.getDay();
|
|
67
|
+
if (startDay !== 0) {
|
|
68
|
+
startDate.setDate(startDate.getDate() - startDay);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
var weeks: Array<Array<{ date: string; datum: CalendarDatum | undefined } | null>> = [];
|
|
72
|
+
var cursor = new Date(startDate);
|
|
73
|
+
var currentWeek: Array<{ date: string; datum: CalendarDatum | undefined } | null> = [];
|
|
74
|
+
|
|
75
|
+
while (cursor <= lastDate) {
|
|
76
|
+
var dow = cursor.getDay();
|
|
77
|
+
if (dow === 0 && currentWeek.length > 0) {
|
|
78
|
+
weeks.push(currentWeek);
|
|
79
|
+
currentWeek = [];
|
|
80
|
+
}
|
|
81
|
+
var key = cursor.getFullYear() + "-" + String(cursor.getMonth() + 1).padStart(2, "0") + "-" + String(cursor.getDate()).padStart(2, "0");
|
|
82
|
+
currentWeek.push({ date: key, datum: dataMap.get(key) });
|
|
83
|
+
cursor.setDate(cursor.getDate() + 1);
|
|
84
|
+
}
|
|
85
|
+
if (currentWeek.length > 0) {
|
|
86
|
+
while (currentWeek.length < 7) currentWeek.push(null);
|
|
87
|
+
weeks.push(currentWeek);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var monthLabels: Array<{ col: number; label: string }> = [];
|
|
91
|
+
var lastMonth = -1;
|
|
92
|
+
for (var wi = 0; wi < weeks.length; wi++) {
|
|
93
|
+
var firstCell = weeks[wi][0];
|
|
94
|
+
if (!firstCell) continue;
|
|
95
|
+
var parts = parseDateParts(firstCell.date);
|
|
96
|
+
if (parts.month !== lastMonth) {
|
|
97
|
+
monthLabels.push({ col: wi, label: MONTH_NAMES[parts.month] });
|
|
98
|
+
lastMonth = parts.month;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
var svgWidth = DAY_LABEL_WIDTH + weeks.length * CELL_STEP;
|
|
103
|
+
var svgHeight = MONTH_LABEL_HEIGHT + 7 * CELL_STEP;
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div className="relative overflow-x-auto">
|
|
107
|
+
<svg width={svgWidth} height={svgHeight} className="block">
|
|
108
|
+
{monthLabels.map(function (ml, idx) {
|
|
109
|
+
return (
|
|
110
|
+
<text
|
|
111
|
+
key={idx}
|
|
112
|
+
x={DAY_LABEL_WIDTH + ml.col * CELL_STEP}
|
|
113
|
+
y={10}
|
|
114
|
+
className="fill-base-content/30"
|
|
115
|
+
style={{ fontSize: 10, fontFamily: "var(--font-mono)" }}
|
|
116
|
+
>
|
|
117
|
+
{ml.label}
|
|
118
|
+
</text>
|
|
119
|
+
);
|
|
120
|
+
})}
|
|
121
|
+
|
|
122
|
+
{DAY_LABELS.map(function (dl) {
|
|
123
|
+
return (
|
|
124
|
+
<text
|
|
125
|
+
key={dl.index}
|
|
126
|
+
x={0}
|
|
127
|
+
y={MONTH_LABEL_HEIGHT + dl.index * CELL_STEP + CELL_SIZE - 1}
|
|
128
|
+
className="fill-base-content/30"
|
|
129
|
+
style={{ fontSize: 10, fontFamily: "var(--font-mono)" }}
|
|
130
|
+
>
|
|
131
|
+
{dl.label}
|
|
132
|
+
</text>
|
|
133
|
+
);
|
|
134
|
+
})}
|
|
135
|
+
|
|
136
|
+
{weeks.map(function (week, col) {
|
|
137
|
+
return week.map(function (cell, row) {
|
|
138
|
+
if (!cell) return null;
|
|
139
|
+
var intensity = getIntensity(cell.datum?.count || 0, maxCount);
|
|
140
|
+
var x = DAY_LABEL_WIDTH + col * CELL_STEP;
|
|
141
|
+
var y = MONTH_LABEL_HEIGHT + row * CELL_STEP;
|
|
142
|
+
var opacity = intensity >= 0 ? INTENSITY_OPACITIES[intensity] : 0.05;
|
|
143
|
+
var fillColor = intensity >= 0 ? PRIMARY_COLOR : undefined;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<rect
|
|
147
|
+
key={cell.date}
|
|
148
|
+
x={x}
|
|
149
|
+
y={y}
|
|
150
|
+
width={CELL_SIZE}
|
|
151
|
+
height={CELL_SIZE}
|
|
152
|
+
rx={2}
|
|
153
|
+
ry={2}
|
|
154
|
+
fill={fillColor || "currentColor"}
|
|
155
|
+
opacity={opacity}
|
|
156
|
+
className={fillColor ? "" : "text-base-content/5"}
|
|
157
|
+
onMouseEnter={function (e) {
|
|
158
|
+
setHover({
|
|
159
|
+
x: e.clientX,
|
|
160
|
+
y: e.clientY,
|
|
161
|
+
datum: cell!.datum || { date: cell!.date, count: 0, tokens: 0, cost: 0 },
|
|
162
|
+
});
|
|
163
|
+
}}
|
|
164
|
+
onMouseLeave={function () { setHover(null); }}
|
|
165
|
+
/>
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
})}
|
|
169
|
+
</svg>
|
|
170
|
+
|
|
171
|
+
{hover && (
|
|
172
|
+
<div
|
|
173
|
+
className="fixed z-50 rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg pointer-events-none"
|
|
174
|
+
style={{ left: hover.x + 12, top: hover.y - 40 }}
|
|
175
|
+
>
|
|
176
|
+
<p className="text-[10px] font-mono text-base-content/50">{hover.datum.date}</p>
|
|
177
|
+
<div className="text-[11px] font-mono text-base-content/70 space-y-0.5">
|
|
178
|
+
<p><span className="text-base-content/40">sessions </span>{hover.datum.count}</p>
|
|
179
|
+
<p><span className="text-base-content/40">cost </span>${hover.datum.cost.toFixed(4)}</p>
|
|
180
|
+
</div>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
interface DailySummaryDatum {
|
|
2
|
+
date: string;
|
|
3
|
+
sessions: number;
|
|
4
|
+
cost: number;
|
|
5
|
+
tokens: number;
|
|
6
|
+
topTool: string;
|
|
7
|
+
modelMix: Record<string, number>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface DailySummaryCardsProps {
|
|
11
|
+
data: DailySummaryDatum[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
var MODEL_COLORS: Record<string, string> = {
|
|
15
|
+
opus: "oklch(55% 0.25 280)",
|
|
16
|
+
sonnet: "#a855f7",
|
|
17
|
+
haiku: "#22c55e",
|
|
18
|
+
other: "#f59e0b",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function formatCardDate(dateStr: string): string {
|
|
22
|
+
var d = new Date(dateStr + "T00:00:00");
|
|
23
|
+
var days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
24
|
+
return days[d.getDay()] + " " + (d.getMonth() + 1) + "/" + d.getDate();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function DailySummaryCards({ data }: DailySummaryCardsProps) {
|
|
28
|
+
if (!data || data.length === 0) {
|
|
29
|
+
return (
|
|
30
|
+
<div className="flex items-center justify-center h-[100px] text-base-content/25 font-mono text-[11px]">
|
|
31
|
+
No data
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
var reversed = data.slice().reverse();
|
|
37
|
+
|
|
38
|
+
return (
|
|
39
|
+
<div className="flex gap-3 overflow-x-auto snap-x snap-mandatory pb-2 scrollbar-thin">
|
|
40
|
+
{reversed.map(function (d) {
|
|
41
|
+
var mixEntries = Object.entries(d.modelMix);
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<div
|
|
45
|
+
key={d.date}
|
|
46
|
+
className="flex-shrink-0 snap-start rounded-lg bg-base-300 border border-base-content/5 px-3 py-2.5 w-[140px]"
|
|
47
|
+
>
|
|
48
|
+
<p className="text-[11px] font-mono font-bold text-base-content/60 mb-1.5">
|
|
49
|
+
{formatCardDate(d.date)}
|
|
50
|
+
</p>
|
|
51
|
+
|
|
52
|
+
<div className="text-[10px] font-mono text-base-content/50 space-y-0.5">
|
|
53
|
+
<p><span className="text-base-content/30">sessions </span>{d.sessions}</p>
|
|
54
|
+
<p><span className="text-base-content/30">cost </span>${d.cost.toFixed(2)}</p>
|
|
55
|
+
{d.topTool && (
|
|
56
|
+
<p className="truncate"><span className="text-base-content/30">tool </span>{d.topTool}</p>
|
|
57
|
+
)}
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
{mixEntries.length > 0 && (
|
|
61
|
+
<div className="mt-2 h-[4px] rounded-full overflow-hidden flex bg-base-content/5">
|
|
62
|
+
{mixEntries.map(function (entry) {
|
|
63
|
+
var model = entry[0];
|
|
64
|
+
var pct = entry[1];
|
|
65
|
+
if (pct <= 0) return null;
|
|
66
|
+
return (
|
|
67
|
+
<div
|
|
68
|
+
key={model}
|
|
69
|
+
style={{
|
|
70
|
+
width: (pct * 100) + "%",
|
|
71
|
+
backgroundColor: MODEL_COLORS[model] || MODEL_COLORS.other,
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
})}
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
})}
|
|
81
|
+
</div>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface HeatmapDatum {
|
|
4
|
+
day: number;
|
|
5
|
+
hour: number;
|
|
6
|
+
count: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
interface HourlyHeatmapProps {
|
|
10
|
+
data: HeatmapDatum[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
var CELL_SIZE = 18;
|
|
14
|
+
var CELL_GAP = 2;
|
|
15
|
+
var CELL_STEP = CELL_SIZE + CELL_GAP;
|
|
16
|
+
var DAY_LABEL_WIDTH = 32;
|
|
17
|
+
var HOUR_LABEL_HEIGHT = 16;
|
|
18
|
+
|
|
19
|
+
var DAY_ORDER = [1, 2, 3, 4, 5, 6, 0];
|
|
20
|
+
var DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
21
|
+
var HOUR_LABELS = [0, 3, 6, 9, 12, 15, 18, 21];
|
|
22
|
+
|
|
23
|
+
var PRIMARY_COLOR = "oklch(55% 0.25 280)";
|
|
24
|
+
|
|
25
|
+
export function HourlyHeatmap({ data }: HourlyHeatmapProps) {
|
|
26
|
+
var [hover, setHover] = useState<{ x: number; y: number; day: string; hour: number; count: number } | null>(null);
|
|
27
|
+
|
|
28
|
+
if (!data || data.length === 0) {
|
|
29
|
+
return (
|
|
30
|
+
<div className="flex items-center justify-center h-[200px] text-base-content/25 font-mono text-[11px]">
|
|
31
|
+
No data
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
var grid = new Map<string, number>();
|
|
37
|
+
var maxCount = 0;
|
|
38
|
+
for (var i = 0; i < data.length; i++) {
|
|
39
|
+
var key = data[i].day + ":" + data[i].hour;
|
|
40
|
+
grid.set(key, data[i].count);
|
|
41
|
+
if (data[i].count > maxCount) maxCount = data[i].count;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
var svgWidth = DAY_LABEL_WIDTH + 24 * CELL_STEP;
|
|
45
|
+
var svgHeight = HOUR_LABEL_HEIGHT + 7 * CELL_STEP;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<div className="relative overflow-x-auto">
|
|
49
|
+
<svg width={svgWidth} height={svgHeight} className="block">
|
|
50
|
+
{HOUR_LABELS.map(function (h) {
|
|
51
|
+
return (
|
|
52
|
+
<text
|
|
53
|
+
key={h}
|
|
54
|
+
x={DAY_LABEL_WIDTH + h * CELL_STEP + CELL_SIZE / 2}
|
|
55
|
+
y={11}
|
|
56
|
+
textAnchor="middle"
|
|
57
|
+
className="fill-base-content/30"
|
|
58
|
+
style={{ fontSize: 10, fontFamily: "var(--font-mono)" }}
|
|
59
|
+
>
|
|
60
|
+
{h}
|
|
61
|
+
</text>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
|
|
65
|
+
{DAY_ORDER.map(function (dayIdx, row) {
|
|
66
|
+
return (
|
|
67
|
+
<text
|
|
68
|
+
key={dayIdx}
|
|
69
|
+
x={0}
|
|
70
|
+
y={HOUR_LABEL_HEIGHT + row * CELL_STEP + CELL_SIZE - 3}
|
|
71
|
+
className="fill-base-content/30"
|
|
72
|
+
style={{ fontSize: 10, fontFamily: "var(--font-mono)" }}
|
|
73
|
+
>
|
|
74
|
+
{DAY_NAMES[dayIdx]}
|
|
75
|
+
</text>
|
|
76
|
+
);
|
|
77
|
+
})}
|
|
78
|
+
|
|
79
|
+
{DAY_ORDER.map(function (dayIdx, row) {
|
|
80
|
+
return Array.from({ length: 24 }, function (_, h) {
|
|
81
|
+
var cellKey = dayIdx + ":" + h;
|
|
82
|
+
var count = grid.get(cellKey) || 0;
|
|
83
|
+
var opacity = maxCount > 0 && count > 0 ? Math.max(0.1, count / maxCount) : 0.05;
|
|
84
|
+
var x = DAY_LABEL_WIDTH + h * CELL_STEP;
|
|
85
|
+
var y = HOUR_LABEL_HEIGHT + row * CELL_STEP;
|
|
86
|
+
var dayName = DAY_NAMES[dayIdx];
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<rect
|
|
90
|
+
key={cellKey}
|
|
91
|
+
x={x}
|
|
92
|
+
y={y}
|
|
93
|
+
width={CELL_SIZE}
|
|
94
|
+
height={CELL_SIZE}
|
|
95
|
+
rx={2}
|
|
96
|
+
ry={2}
|
|
97
|
+
fill={count > 0 ? PRIMARY_COLOR : "currentColor"}
|
|
98
|
+
opacity={opacity}
|
|
99
|
+
className={count > 0 ? "" : "text-base-content/5"}
|
|
100
|
+
onMouseEnter={function (e) {
|
|
101
|
+
setHover({
|
|
102
|
+
x: e.clientX,
|
|
103
|
+
y: e.clientY,
|
|
104
|
+
day: dayName,
|
|
105
|
+
hour: h,
|
|
106
|
+
count: count,
|
|
107
|
+
});
|
|
108
|
+
}}
|
|
109
|
+
onMouseLeave={function () { setHover(null); }}
|
|
110
|
+
/>
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
})}
|
|
114
|
+
</svg>
|
|
115
|
+
|
|
116
|
+
{hover && (
|
|
117
|
+
<div
|
|
118
|
+
className="fixed z-50 rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg pointer-events-none"
|
|
119
|
+
style={{ left: hover.x + 12, top: hover.y - 40 }}
|
|
120
|
+
>
|
|
121
|
+
<div className="text-[11px] font-mono text-base-content/70 space-y-0.5">
|
|
122
|
+
<p><span className="text-base-content/40">{hover.day} </span>{hover.hour}:00</p>
|
|
123
|
+
<p><span className="text-base-content/40">sessions </span>{hover.count}</p>
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
)}
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
interface TimelineDatum {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
project: string;
|
|
7
|
+
start: number;
|
|
8
|
+
end: number;
|
|
9
|
+
cost: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface SessionTimelineProps {
|
|
13
|
+
data: TimelineDatum[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
var ROW_HEIGHT = 16;
|
|
17
|
+
var ROW_GAP = 2;
|
|
18
|
+
var ROW_STEP = ROW_HEIGHT + ROW_GAP;
|
|
19
|
+
var LEFT_MARGIN = 8;
|
|
20
|
+
var RIGHT_MARGIN = 8;
|
|
21
|
+
|
|
22
|
+
var PROJECT_PALETTE = [
|
|
23
|
+
"oklch(55% 0.25 280)",
|
|
24
|
+
"#a855f7",
|
|
25
|
+
"#22c55e",
|
|
26
|
+
"#f59e0b",
|
|
27
|
+
"oklch(65% 0.2 240)",
|
|
28
|
+
"oklch(65% 0.25 25)",
|
|
29
|
+
"oklch(65% 0.25 150)",
|
|
30
|
+
"oklch(70% 0.2 60)",
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function formatTime(ts: number): string {
|
|
34
|
+
var d = new Date(ts);
|
|
35
|
+
return (d.getMonth() + 1) + "/" + d.getDate() + " " + String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function SessionTimeline({ data }: SessionTimelineProps) {
|
|
39
|
+
var [hover, setHover] = useState<{ x: number; y: number; datum: TimelineDatum } | null>(null);
|
|
40
|
+
|
|
41
|
+
if (!data || data.length === 0) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="flex items-center justify-center h-[200px] text-base-content/25 font-mono text-[11px]">
|
|
44
|
+
No data
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
var projects = Array.from(new Set(data.map(function (d) { return d.project; })));
|
|
50
|
+
function getColor(project: string): string {
|
|
51
|
+
var idx = projects.indexOf(project);
|
|
52
|
+
return PROJECT_PALETTE[idx % PROJECT_PALETTE.length];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
var minTime = Infinity;
|
|
56
|
+
var maxTime = -Infinity;
|
|
57
|
+
for (var i = 0; i < data.length; i++) {
|
|
58
|
+
if (data[i].start < minTime) minTime = data[i].start;
|
|
59
|
+
if (data[i].end > maxTime) maxTime = data[i].end;
|
|
60
|
+
}
|
|
61
|
+
var timeRange = maxTime - minTime || 1;
|
|
62
|
+
|
|
63
|
+
var svgHeight = data.length * ROW_STEP;
|
|
64
|
+
var maxHeight = 300;
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="relative overflow-y-auto overflow-x-hidden" style={{ maxHeight: maxHeight }}>
|
|
68
|
+
<svg width="100%" height={svgHeight} className="block" viewBox={"0 0 600 " + svgHeight} preserveAspectRatio="none">
|
|
69
|
+
{data.map(function (d, idx) {
|
|
70
|
+
var barWidth = 600 - LEFT_MARGIN - RIGHT_MARGIN;
|
|
71
|
+
var x1 = LEFT_MARGIN + ((d.start - minTime) / timeRange) * barWidth;
|
|
72
|
+
var x2 = LEFT_MARGIN + ((d.end - minTime) / timeRange) * barWidth;
|
|
73
|
+
var w = Math.max(x2 - x1, 3);
|
|
74
|
+
var y = idx * ROW_STEP;
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<rect
|
|
78
|
+
key={d.id}
|
|
79
|
+
x={x1}
|
|
80
|
+
y={y}
|
|
81
|
+
width={w}
|
|
82
|
+
height={ROW_HEIGHT}
|
|
83
|
+
rx={3}
|
|
84
|
+
ry={3}
|
|
85
|
+
fill={getColor(d.project)}
|
|
86
|
+
opacity={0.7}
|
|
87
|
+
onMouseEnter={function (e) {
|
|
88
|
+
setHover({ x: e.clientX, y: e.clientY, datum: d });
|
|
89
|
+
}}
|
|
90
|
+
onMouseLeave={function () { setHover(null); }}
|
|
91
|
+
/>
|
|
92
|
+
);
|
|
93
|
+
})}
|
|
94
|
+
</svg>
|
|
95
|
+
|
|
96
|
+
{hover && (
|
|
97
|
+
<div
|
|
98
|
+
className="fixed z-50 rounded-lg border border-base-content/8 bg-base-200 px-3 py-2 shadow-lg pointer-events-none max-w-[200px]"
|
|
99
|
+
style={{ left: hover.x + 12, top: hover.y - 50 }}
|
|
100
|
+
>
|
|
101
|
+
<p className="text-[10px] font-mono text-base-content/50 mb-1 truncate">{hover.datum.title}</p>
|
|
102
|
+
<div className="text-[11px] font-mono text-base-content/70 space-y-0.5">
|
|
103
|
+
<p><span className="text-base-content/40">project </span>{hover.datum.project}</p>
|
|
104
|
+
<p><span className="text-base-content/40">start </span>{formatTime(hover.datum.start)}</p>
|
|
105
|
+
<p><span className="text-base-content/40">end </span>{formatTime(hover.datum.end)}</p>
|
|
106
|
+
<p><span className="text-base-content/40">cost </span>${hover.datum.cost.toFixed(4)}</p>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cryptiklemur/lattice",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.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>",
|
|
@@ -520,6 +520,124 @@ function aggregate(sessions: SessionData[], period: AnalyticsPeriod): AnalyticsP
|
|
|
520
520
|
|
|
521
521
|
var tokenFlowSankey: AnalyticsPayload["tokenFlowSankey"] = { nodes: sankeyNodes, links: sankeyLinks };
|
|
522
522
|
|
|
523
|
+
var activityCalendarMap = new Map<string, { count: number; tokens: number; cost: number }>();
|
|
524
|
+
for (var aci = 0; aci < filtered.length; aci++) {
|
|
525
|
+
var acSess = filtered[aci];
|
|
526
|
+
var acDate = formatDate(acSess.endTime > 0 ? acSess.endTime : acSess.startTime);
|
|
527
|
+
var acEntry = activityCalendarMap.get(acDate);
|
|
528
|
+
if (!acEntry) {
|
|
529
|
+
acEntry = { count: 0, tokens: 0, cost: 0 };
|
|
530
|
+
activityCalendarMap.set(acDate, acEntry);
|
|
531
|
+
}
|
|
532
|
+
acEntry.count++;
|
|
533
|
+
acEntry.tokens += acSess.inputTokens + acSess.outputTokens;
|
|
534
|
+
acEntry.cost += acSess.cost;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
var activityCalendar: AnalyticsPayload["activityCalendar"] = [];
|
|
538
|
+
if (dates.length > 0) {
|
|
539
|
+
var calStart = new Date(dates[0]);
|
|
540
|
+
var calEnd = new Date(dates[dates.length - 1]);
|
|
541
|
+
var calCursor = new Date(calStart);
|
|
542
|
+
while (calCursor <= calEnd) {
|
|
543
|
+
var calKey = formatDate(calCursor.getTime());
|
|
544
|
+
var calData = activityCalendarMap.get(calKey);
|
|
545
|
+
activityCalendar.push({
|
|
546
|
+
date: calKey,
|
|
547
|
+
count: calData ? calData.count : 0,
|
|
548
|
+
tokens: calData ? calData.tokens : 0,
|
|
549
|
+
cost: calData ? calData.cost : 0,
|
|
550
|
+
});
|
|
551
|
+
calCursor.setDate(calCursor.getDate() + 1);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
var hourlyHeatmap: AnalyticsPayload["hourlyHeatmap"] = [];
|
|
556
|
+
var heatmapGrid = new Map<string, number>();
|
|
557
|
+
for (var hmi = 0; hmi < filtered.length; hmi++) {
|
|
558
|
+
var hmSess = filtered[hmi];
|
|
559
|
+
if (hmSess.startTime <= 0) continue;
|
|
560
|
+
var hmDate = new Date(hmSess.startTime);
|
|
561
|
+
var hmDay = hmDate.getDay();
|
|
562
|
+
var hmHour = hmDate.getHours();
|
|
563
|
+
var hmKey = hmDay + ":" + hmHour;
|
|
564
|
+
heatmapGrid.set(hmKey, (heatmapGrid.get(hmKey) || 0) + 1);
|
|
565
|
+
}
|
|
566
|
+
for (var hd = 0; hd < 7; hd++) {
|
|
567
|
+
for (var hh = 0; hh < 24; hh++) {
|
|
568
|
+
var hhKey = hd + ":" + hh;
|
|
569
|
+
hourlyHeatmap.push({ day: hd, hour: hh, count: heatmapGrid.get(hhKey) || 0 });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
var sessionTimeline: AnalyticsPayload["sessionTimeline"] = [];
|
|
574
|
+
var tlSorted = filtered
|
|
575
|
+
.filter(function (s) { return s.startTime > 0 && s.endTime > 0; })
|
|
576
|
+
.sort(function (a, b) { return b.startTime - a.startTime; });
|
|
577
|
+
var tlCap = Math.min(tlSorted.length, 50);
|
|
578
|
+
for (var tli = 0; tli < tlCap; tli++) {
|
|
579
|
+
var tlSess = tlSorted[tli];
|
|
580
|
+
sessionTimeline.push({
|
|
581
|
+
id: tlSess.id,
|
|
582
|
+
title: tlSess.title,
|
|
583
|
+
project: tlSess.project,
|
|
584
|
+
start: tlSess.startTime,
|
|
585
|
+
end: tlSess.endTime,
|
|
586
|
+
cost: tlSess.cost,
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
var dailySummaryMap = new Map<string, { sessions: number; cost: number; tokens: number; tools: Map<string, number>; models: Map<string, number> }>();
|
|
591
|
+
for (var dsi = 0; dsi < filtered.length; dsi++) {
|
|
592
|
+
var dsSess = filtered[dsi];
|
|
593
|
+
var dsDate = formatDate(dsSess.endTime > 0 ? dsSess.endTime : dsSess.startTime);
|
|
594
|
+
var dsEntry = dailySummaryMap.get(dsDate);
|
|
595
|
+
if (!dsEntry) {
|
|
596
|
+
dsEntry = { sessions: 0, cost: 0, tokens: 0, tools: new Map(), models: new Map() };
|
|
597
|
+
dailySummaryMap.set(dsDate, dsEntry);
|
|
598
|
+
}
|
|
599
|
+
dsEntry.sessions++;
|
|
600
|
+
dsEntry.cost += dsSess.cost;
|
|
601
|
+
dsEntry.tokens += dsSess.inputTokens + dsSess.outputTokens;
|
|
602
|
+
dsSess.tools.forEach(function (count, tool) {
|
|
603
|
+
dsEntry!.tools.set(tool, (dsEntry!.tools.get(tool) || 0) + count);
|
|
604
|
+
});
|
|
605
|
+
dsSess.models.forEach(function (val, key) {
|
|
606
|
+
dsEntry!.models.set(key, (dsEntry!.models.get(key) || 0) + val.cost);
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
var dailySummaries: AnalyticsPayload["dailySummaries"] = [];
|
|
611
|
+
var dsSortedDates = Array.from(dailySummaryMap.keys()).sort();
|
|
612
|
+
for (var dsdi = 0; dsdi < dsSortedDates.length; dsdi++) {
|
|
613
|
+
var dsd = dsSortedDates[dsdi];
|
|
614
|
+
var dsData = dailySummaryMap.get(dsd)!;
|
|
615
|
+
var topTool = "";
|
|
616
|
+
var topToolCount = 0;
|
|
617
|
+
dsData.tools.forEach(function (count, tool) {
|
|
618
|
+
if (count > topToolCount) {
|
|
619
|
+
topToolCount = count;
|
|
620
|
+
topTool = tool;
|
|
621
|
+
}
|
|
622
|
+
});
|
|
623
|
+
var modelMix: Record<string, number> = {};
|
|
624
|
+
var modelTotal = 0;
|
|
625
|
+
dsData.models.forEach(function (cost) { modelTotal += cost; });
|
|
626
|
+
if (modelTotal > 0) {
|
|
627
|
+
dsData.models.forEach(function (cost, model) {
|
|
628
|
+
modelMix[model] = Math.round((cost / modelTotal) * 100) / 100;
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
dailySummaries.push({
|
|
632
|
+
date: dsd,
|
|
633
|
+
sessions: dsData.sessions,
|
|
634
|
+
cost: dsData.cost,
|
|
635
|
+
tokens: dsData.tokens,
|
|
636
|
+
topTool: topTool,
|
|
637
|
+
modelMix: modelMix,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
|
|
523
641
|
return {
|
|
524
642
|
totalCost: totalCost,
|
|
525
643
|
totalSessions: filtered.length,
|
|
@@ -545,6 +663,10 @@ function aggregate(sessions: SessionData[], period: AnalyticsPeriod): AnalyticsP
|
|
|
545
663
|
responseTimeData: responseTimeData,
|
|
546
664
|
contextUtilization: contextUtilization,
|
|
547
665
|
tokenFlowSankey: tokenFlowSankey,
|
|
666
|
+
activityCalendar: activityCalendar,
|
|
667
|
+
hourlyHeatmap: hourlyHeatmap,
|
|
668
|
+
sessionTimeline: sessionTimeline,
|
|
669
|
+
dailySummaries: dailySummaries,
|
|
548
670
|
};
|
|
549
671
|
}
|
|
550
672
|
|
package/shared/src/analytics.ts
CHANGED
|
@@ -22,6 +22,11 @@ export interface AnalyticsPayload {
|
|
|
22
22
|
responseTimeData: Array<{ tokens: number; duration: number; model: string; sessionId: string }>;
|
|
23
23
|
contextUtilization: Array<{ messageIndex: number; contextPercent: number; sessionId: string; title: string }>;
|
|
24
24
|
tokenFlowSankey: { nodes: Array<{ name: string }>; links: Array<{ source: number; target: number; value: number }> };
|
|
25
|
+
|
|
26
|
+
activityCalendar: Array<{ date: string; count: number; tokens: number; cost: number }>;
|
|
27
|
+
hourlyHeatmap: Array<{ day: number; hour: number; count: number }>;
|
|
28
|
+
sessionTimeline: Array<{ id: string; title: string; project: string; start: number; end: number; cost: number }>;
|
|
29
|
+
dailySummaries: Array<{ date: string; sessions: number; cost: number; tokens: number; topTool: string; modelMix: Record<string, number> }>;
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
export type AnalyticsPeriod = "24h" | "7d" | "30d" | "90d" | "all";
|