@cryptiklemur/lattice 1.6.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 +66 -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/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/SessionTimeline.tsx +112 -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 +219 -0
- package/shared/src/analytics.ts +11 -0
|
@@ -32,6 +32,15 @@ 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";
|
|
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";
|
|
35
44
|
|
|
36
45
|
export function AnalyticsView() {
|
|
37
46
|
var analytics = useAnalytics();
|
|
@@ -107,6 +116,63 @@ export function AnalyticsView() {
|
|
|
107
116
|
</ChartErrorBoundary>
|
|
108
117
|
</ChartCard>
|
|
109
118
|
</div>
|
|
119
|
+
|
|
120
|
+
<ChartCard title="Activity Calendar">
|
|
121
|
+
<ChartErrorBoundary name="Calendar">
|
|
122
|
+
<ActivityCalendar data={analytics.data.activityCalendar} />
|
|
123
|
+
</ChartErrorBoundary>
|
|
124
|
+
</ChartCard>
|
|
125
|
+
|
|
126
|
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
127
|
+
<ChartCard title="Hourly Activity">
|
|
128
|
+
<ChartErrorBoundary name="HourlyHeatmap">
|
|
129
|
+
<HourlyHeatmap data={analytics.data.hourlyHeatmap} />
|
|
130
|
+
</ChartErrorBoundary>
|
|
131
|
+
</ChartCard>
|
|
132
|
+
<ChartCard title="Session Timeline">
|
|
133
|
+
<ChartErrorBoundary name="Timeline">
|
|
134
|
+
<SessionTimeline data={analytics.data.sessionTimeline} />
|
|
135
|
+
</ChartErrorBoundary>
|
|
136
|
+
</ChartCard>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<ChartCard title="Daily Summary">
|
|
140
|
+
<ChartErrorBoundary name="DailySummary">
|
|
141
|
+
<DailySummaryCards data={analytics.data.dailySummaries} />
|
|
142
|
+
</ChartErrorBoundary>
|
|
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>
|
|
110
176
|
</div>
|
|
111
177
|
)}
|
|
112
178
|
|
|
@@ -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,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
|
+
}
|