@creezio/observability 0.22.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.
Files changed (209) hide show
  1. package/README.md +328 -0
  2. package/dist/.creezio-src-hash.json +4 -0
  3. package/dist/api-endpoints/http-routes.d.ts +11 -0
  4. package/dist/api-endpoints/http-routes.d.ts.map +1 -0
  5. package/dist/api-endpoints/http-routes.js +11 -0
  6. package/dist/api-endpoints/http-routes.js.map +1 -0
  7. package/dist/api-endpoints/index.d.ts +4 -0
  8. package/dist/api-endpoints/index.d.ts.map +1 -0
  9. package/dist/api-endpoints/index.js +3 -0
  10. package/dist/api-endpoints/index.js.map +1 -0
  11. package/dist/api-endpoints/registry.d.ts +66 -0
  12. package/dist/api-endpoints/registry.d.ts.map +1 -0
  13. package/dist/api-endpoints/registry.js +102 -0
  14. package/dist/api-endpoints/registry.js.map +1 -0
  15. package/dist/api-mount.d.ts +7 -0
  16. package/dist/api-mount.d.ts.map +1 -0
  17. package/dist/api-mount.js +114 -0
  18. package/dist/api-mount.js.map +1 -0
  19. package/dist/helpers.d.ts +23 -0
  20. package/dist/helpers.d.ts.map +1 -0
  21. package/dist/helpers.js +36 -0
  22. package/dist/helpers.js.map +1 -0
  23. package/dist/index.d.ts +35 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +24 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/memory-store.d.ts +3 -0
  28. package/dist/memory-store.d.ts.map +1 -0
  29. package/dist/memory-store.js +113 -0
  30. package/dist/memory-store.js.map +1 -0
  31. package/dist/ops/emit.d.ts +8 -0
  32. package/dist/ops/emit.d.ts.map +1 -0
  33. package/dist/ops/emit.js +15 -0
  34. package/dist/ops/emit.js.map +1 -0
  35. package/dist/ops/fleet-activity.d.ts +50 -0
  36. package/dist/ops/fleet-activity.d.ts.map +1 -0
  37. package/dist/ops/fleet-activity.js +123 -0
  38. package/dist/ops/fleet-activity.js.map +1 -0
  39. package/dist/ops/fleet-agent.d.ts +102 -0
  40. package/dist/ops/fleet-agent.d.ts.map +1 -0
  41. package/dist/ops/fleet-agent.js +352 -0
  42. package/dist/ops/fleet-agent.js.map +1 -0
  43. package/dist/ops/fleet-samples.d.ts +33 -0
  44. package/dist/ops/fleet-samples.d.ts.map +1 -0
  45. package/dist/ops/fleet-samples.js +223 -0
  46. package/dist/ops/fleet-samples.js.map +1 -0
  47. package/dist/ops/journal.d.ts +56 -0
  48. package/dist/ops/journal.d.ts.map +1 -0
  49. package/dist/ops/journal.js +307 -0
  50. package/dist/ops/journal.js.map +1 -0
  51. package/dist/ops/rules.d.ts +16 -0
  52. package/dist/ops/rules.d.ts.map +1 -0
  53. package/dist/ops/rules.js +98 -0
  54. package/dist/ops/rules.js.map +1 -0
  55. package/dist/ops/types.d.ts +69 -0
  56. package/dist/ops/types.d.ts.map +1 -0
  57. package/dist/ops/types.js +119 -0
  58. package/dist/ops/types.js.map +1 -0
  59. package/dist/request-logs/config.d.ts +19 -0
  60. package/dist/request-logs/config.d.ts.map +1 -0
  61. package/dist/request-logs/config.js +36 -0
  62. package/dist/request-logs/config.js.map +1 -0
  63. package/dist/request-logs/http-routes.d.ts +8 -0
  64. package/dist/request-logs/http-routes.d.ts.map +1 -0
  65. package/dist/request-logs/http-routes.js +29 -0
  66. package/dist/request-logs/http-routes.js.map +1 -0
  67. package/dist/request-logs/index.d.ts +6 -0
  68. package/dist/request-logs/index.d.ts.map +1 -0
  69. package/dist/request-logs/index.js +5 -0
  70. package/dist/request-logs/index.js.map +1 -0
  71. package/dist/request-logs/middleware.d.ts +11 -0
  72. package/dist/request-logs/middleware.d.ts.map +1 -0
  73. package/dist/request-logs/middleware.js +198 -0
  74. package/dist/request-logs/middleware.js.map +1 -0
  75. package/dist/request-logs/request-logs.d.ts +60 -0
  76. package/dist/request-logs/request-logs.d.ts.map +1 -0
  77. package/dist/request-logs/request-logs.js +260 -0
  78. package/dist/request-logs/request-logs.js.map +1 -0
  79. package/dist/schema.d.ts +3 -0
  80. package/dist/schema.d.ts.map +1 -0
  81. package/dist/schema.js +21 -0
  82. package/dist/schema.js.map +1 -0
  83. package/dist/sqlite-driver.d.ts +16 -0
  84. package/dist/sqlite-driver.d.ts.map +1 -0
  85. package/dist/sqlite-driver.js +15 -0
  86. package/dist/sqlite-driver.js.map +1 -0
  87. package/dist/sqlite-store.d.ts +12 -0
  88. package/dist/sqlite-store.d.ts.map +1 -0
  89. package/dist/sqlite-store.js +176 -0
  90. package/dist/sqlite-store.js.map +1 -0
  91. package/dist/types.d.ts +67 -0
  92. package/dist/types.d.ts.map +1 -0
  93. package/dist/types.js +9 -0
  94. package/dist/types.js.map +1 -0
  95. package/dist/usage/adapters.d.ts +31 -0
  96. package/dist/usage/adapters.d.ts.map +1 -0
  97. package/dist/usage/adapters.js +37 -0
  98. package/dist/usage/adapters.js.map +1 -0
  99. package/dist/usage/http-routes.d.ts +23 -0
  100. package/dist/usage/http-routes.d.ts.map +1 -0
  101. package/dist/usage/http-routes.js +150 -0
  102. package/dist/usage/http-routes.js.map +1 -0
  103. package/dist/usage/index.d.ts +11 -0
  104. package/dist/usage/index.d.ts.map +1 -0
  105. package/dist/usage/index.js +7 -0
  106. package/dist/usage/index.js.map +1 -0
  107. package/dist/usage/ui-brand.d.ts +23 -0
  108. package/dist/usage/ui-brand.d.ts.map +1 -0
  109. package/dist/usage/ui-brand.js +18 -0
  110. package/dist/usage/ui-brand.js.map +1 -0
  111. package/dist/usage/usage-analytics-productivity.d.ts +73 -0
  112. package/dist/usage/usage-analytics-productivity.d.ts.map +1 -0
  113. package/dist/usage/usage-analytics-productivity.js +317 -0
  114. package/dist/usage/usage-analytics-productivity.js.map +1 -0
  115. package/dist/usage/usage-analytics-shared.d.ts +5 -0
  116. package/dist/usage/usage-analytics-shared.d.ts.map +1 -0
  117. package/dist/usage/usage-analytics-shared.js +20 -0
  118. package/dist/usage/usage-analytics-shared.js.map +1 -0
  119. package/dist/usage/usage-analytics.d.ts +127 -0
  120. package/dist/usage/usage-analytics.d.ts.map +1 -0
  121. package/dist/usage/usage-analytics.js +338 -0
  122. package/dist/usage/usage-analytics.js.map +1 -0
  123. package/dist-cjs/api-endpoints/http-routes.js +14 -0
  124. package/dist-cjs/api-endpoints/http-routes.js.map +1 -0
  125. package/dist-cjs/api-endpoints/index.js +10 -0
  126. package/dist-cjs/api-endpoints/index.js.map +1 -0
  127. package/dist-cjs/api-endpoints/registry.js +107 -0
  128. package/dist-cjs/api-endpoints/registry.js.map +1 -0
  129. package/dist-cjs/api-mount.js +117 -0
  130. package/dist-cjs/api-mount.js.map +1 -0
  131. package/dist-cjs/helpers.js +41 -0
  132. package/dist-cjs/helpers.js.map +1 -0
  133. package/dist-cjs/index.js +118 -0
  134. package/dist-cjs/index.js.map +1 -0
  135. package/dist-cjs/memory-store.js +119 -0
  136. package/dist-cjs/memory-store.js.map +1 -0
  137. package/dist-cjs/ops/emit.js +18 -0
  138. package/dist-cjs/ops/emit.js.map +1 -0
  139. package/dist-cjs/ops/fleet-activity.js +131 -0
  140. package/dist-cjs/ops/fleet-activity.js.map +1 -0
  141. package/dist-cjs/ops/fleet-agent.js +358 -0
  142. package/dist-cjs/ops/fleet-agent.js.map +1 -0
  143. package/dist-cjs/ops/fleet-samples.js +229 -0
  144. package/dist-cjs/ops/fleet-samples.js.map +1 -0
  145. package/dist-cjs/ops/journal.js +326 -0
  146. package/dist-cjs/ops/journal.js.map +1 -0
  147. package/dist-cjs/ops/rules.js +102 -0
  148. package/dist-cjs/ops/rules.js.map +1 -0
  149. package/dist-cjs/ops/types.js +126 -0
  150. package/dist-cjs/ops/types.js.map +1 -0
  151. package/dist-cjs/package.json +3 -0
  152. package/dist-cjs/request-logs/config.js +42 -0
  153. package/dist-cjs/request-logs/config.js.map +1 -0
  154. package/dist-cjs/request-logs/http-routes.js +32 -0
  155. package/dist-cjs/request-logs/http-routes.js.map +1 -0
  156. package/dist-cjs/request-logs/index.js +27 -0
  157. package/dist-cjs/request-logs/index.js.map +1 -0
  158. package/dist-cjs/request-logs/middleware.js +203 -0
  159. package/dist-cjs/request-logs/middleware.js.map +1 -0
  160. package/dist-cjs/request-logs/request-logs.js +277 -0
  161. package/dist-cjs/request-logs/request-logs.js.map +1 -0
  162. package/dist-cjs/schema.js +24 -0
  163. package/dist-cjs/schema.js.map +1 -0
  164. package/dist-cjs/sqlite-driver.js +18 -0
  165. package/dist-cjs/sqlite-driver.js.map +1 -0
  166. package/dist-cjs/sqlite-store.js +182 -0
  167. package/dist-cjs/sqlite-store.js.map +1 -0
  168. package/dist-cjs/types.js +12 -0
  169. package/dist-cjs/types.js.map +1 -0
  170. package/dist-cjs/usage/adapters.js +47 -0
  171. package/dist-cjs/usage/adapters.js.map +1 -0
  172. package/dist-cjs/usage/http-routes.js +154 -0
  173. package/dist-cjs/usage/http-routes.js.map +1 -0
  174. package/dist-cjs/usage/index.js +34 -0
  175. package/dist-cjs/usage/index.js.map +1 -0
  176. package/dist-cjs/usage/ui-brand.js +23 -0
  177. package/dist-cjs/usage/ui-brand.js.map +1 -0
  178. package/dist-cjs/usage/usage-analytics-productivity.js +321 -0
  179. package/dist-cjs/usage/usage-analytics-productivity.js.map +1 -0
  180. package/dist-cjs/usage/usage-analytics-shared.js +23 -0
  181. package/dist-cjs/usage/usage-analytics-shared.js.map +1 -0
  182. package/dist-cjs/usage/usage-analytics.js +353 -0
  183. package/dist-cjs/usage/usage-analytics.js.map +1 -0
  184. package/fleet-collector/AGENTS.md +7 -0
  185. package/fleet-collector/README.md +83 -0
  186. package/fleet-collector/configure-fleet-npm.sh +108 -0
  187. package/fleet-collector/env.mjs +174 -0
  188. package/fleet-collector/ops-api.mjs +455 -0
  189. package/fleet-collector/public/admin.html +696 -0
  190. package/fleet-collector/public/index.html +941 -0
  191. package/fleet-collector/server.mjs +564 -0
  192. package/fleet-collector/test-fleet-collector.mjs +413 -0
  193. package/fleet-collector/test-server-admin.mjs +475 -0
  194. package/package.json +109 -0
  195. package/scripts/test-api-endpoints-registry.mjs +60 -0
  196. package/ui/analytics-client.tsx +834 -0
  197. package/ui/analytics-productivity-panel.tsx +513 -0
  198. package/ui/api-endpoints-client.tsx +228 -0
  199. package/ui/index.ts +39 -0
  200. package/ui/primitives/badge.tsx +37 -0
  201. package/ui/primitives/button.tsx +44 -0
  202. package/ui/primitives/card.tsx +44 -0
  203. package/ui/primitives/cn.ts +6 -0
  204. package/ui/primitives/input.tsx +19 -0
  205. package/ui/primitives/tabs.tsx +51 -0
  206. package/ui/request-logs-client.tsx +327 -0
  207. package/ui/session-usage-analytics-provider.tsx +28 -0
  208. package/ui/usage-analytics-client.ts +479 -0
  209. package/ui/usage-analytics-provider.tsx +84 -0
@@ -0,0 +1,834 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import {
5
+ Area,
6
+ AreaChart,
7
+ CartesianGrid,
8
+ ResponsiveContainer,
9
+ Tooltip,
10
+ XAxis,
11
+ YAxis,
12
+ } from "recharts";
13
+ import {
14
+ Activity,
15
+ Bot,
16
+ Clock3,
17
+ Eye,
18
+ Filter,
19
+ MousePointerClick,
20
+ RefreshCw,
21
+ Trash2,
22
+ Users,
23
+ } from "lucide-react";
24
+ import { Badge } from "./primitives/badge";
25
+ import { Button } from "./primitives/button";
26
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "./primitives/tabs";
27
+ import {
28
+ AnalyticsProductivityPanel,
29
+ type ProductivityPayload,
30
+ } from "./analytics-productivity-panel";
31
+ import { cn } from "./primitives/cn";
32
+ import { formatDuration, type UsagePeriod } from "../dist/usage/usage-analytics-shared.js";
33
+
34
+ type KindFilter = "all" | "human" | "ai";
35
+
36
+ type OverviewResponse = {
37
+ period: { from: string; to: string };
38
+ totals: {
39
+ events: number;
40
+ pageViews: number;
41
+ clicks: number;
42
+ sessions: number;
43
+ activeUsers: number;
44
+ activeHumans: number;
45
+ activeAi: number;
46
+ totalTimeMs: number;
47
+ avgSessionTimeMs: number;
48
+ };
49
+ byKind: Array<{ kind: string; events: number; users: number; timeMs: number }>;
50
+ };
51
+
52
+ type TimelinePoint = {
53
+ bucket: string;
54
+ events: number;
55
+ pageViews: number;
56
+ clicks: number;
57
+ humans: number;
58
+ ai: number;
59
+ timeMs: number;
60
+ };
61
+
62
+ type PageStat = {
63
+ path: string;
64
+ views: number;
65
+ uniqueUsers: number;
66
+ avgTimeMs: number;
67
+ totalTimeMs: number;
68
+ };
69
+
70
+ type ClickStat = {
71
+ label: string;
72
+ clicks: number;
73
+ uniqueUsers: number;
74
+ paths: number;
75
+ };
76
+
77
+ type UserStat = {
78
+ userId: string;
79
+ username: string;
80
+ kind: string;
81
+ role: string | null;
82
+ events: number;
83
+ pageViews: number;
84
+ clicks: number;
85
+ sessions: number;
86
+ timeMs: number;
87
+ lastSeen: string | null;
88
+ };
89
+
90
+ type EventRow = {
91
+ id: number;
92
+ created_at: string;
93
+ event_type: string;
94
+ category: string;
95
+ label: string | null;
96
+ path: string | null;
97
+ username: string | null;
98
+ user_kind: string;
99
+ duration_ms: number | null;
100
+ };
101
+
102
+ const PERIODS: Array<{ id: UsagePeriod; label: string }> = [
103
+ { id: "day", label: "24 h" },
104
+ { id: "week", label: "7 j" },
105
+ { id: "month", label: "30 j" },
106
+ { id: "year", label: "12 mois" },
107
+ ];
108
+
109
+ const KINDS: Array<{ id: KindFilter; label: string }> = [
110
+ { id: "all", label: "Tous" },
111
+ { id: "human", label: "Humains" },
112
+ { id: "ai", label: "IA" },
113
+ ];
114
+
115
+ function formatTs(iso: string): string {
116
+ try {
117
+ return new Date(iso).toLocaleString("fr-FR", {
118
+ day: "2-digit",
119
+ month: "short",
120
+ hour: "2-digit",
121
+ minute: "2-digit",
122
+ second: "2-digit",
123
+ });
124
+ } catch {
125
+ return iso;
126
+ }
127
+ }
128
+
129
+ function formatBucket(bucket: string, period: UsagePeriod): string {
130
+ if (period === "day") return bucket.slice(11, 16) || bucket;
131
+ if (period === "year") return bucket;
132
+ return bucket.slice(5);
133
+ }
134
+
135
+ function kindBadge(kind: string) {
136
+ if (kind === "ai") {
137
+ return (
138
+ <Badge variant="warning" className="gap-1">
139
+ <Bot className="h-3 w-3" />
140
+ IA
141
+ </Badge>
142
+ );
143
+ }
144
+ if (kind === "human") {
145
+ return <Badge variant="success">Humain</Badge>;
146
+ }
147
+ return <Badge variant="muted">{kind || "—"}</Badge>;
148
+ }
149
+
150
+ function KpiCard({
151
+ label,
152
+ value,
153
+ hint,
154
+ icon: Icon,
155
+ accent,
156
+ }: {
157
+ label: string;
158
+ value: string;
159
+ hint?: string;
160
+ icon: React.ComponentType<{ className?: string }>;
161
+ accent: string;
162
+ }) {
163
+ return (
164
+ <div className="relative overflow-hidden rounded-2xl border border-slate-200/80 bg-white p-4 shadow-sm">
165
+ <div
166
+ className={cn(
167
+ "pointer-events-none absolute -right-6 -top-6 h-24 w-24 rounded-full opacity-[0.12]",
168
+ accent,
169
+ )}
170
+ />
171
+ <div className="flex items-start justify-between gap-3">
172
+ <div>
173
+ <p className="text-xs font-medium uppercase tracking-wide text-slate-500">
174
+ {label}
175
+ </p>
176
+ <p className="mt-1 text-2xl font-semibold tabular-nums tracking-tight text-slate-900">
177
+ {value}
178
+ </p>
179
+ {hint ? <p className="mt-1 text-xs text-slate-500">{hint}</p> : null}
180
+ </div>
181
+ <div
182
+ className={cn(
183
+ "flex h-10 w-10 items-center justify-center rounded-xl text-white shadow-sm",
184
+ accent,
185
+ )}
186
+ >
187
+ <Icon className="h-5 w-5" />
188
+ </div>
189
+ </div>
190
+ </div>
191
+ );
192
+ }
193
+
194
+ function RankBar({ value, max }: { value: number; max: number }) {
195
+ const pct = max > 0 ? Math.max(4, Math.round((value / max) * 100)) : 0;
196
+ return (
197
+ <div className="h-1.5 w-full overflow-hidden rounded-full bg-slate-100">
198
+ <div
199
+ className="h-full rounded-full bg-sky-500 transition-all duration-500"
200
+ style={{ width: `${pct}%` }}
201
+ />
202
+ </div>
203
+ );
204
+ }
205
+
206
+ export function AnalyticsClient() {
207
+ const [period, setPeriod] = useState<UsagePeriod>("week");
208
+ const [kind, setKind] = useState<KindFilter>("all");
209
+ const [userId, setUserId] = useState<string>("");
210
+ const [loading, setLoading] = useState(true);
211
+ const [error, setError] = useState<string | null>(null);
212
+ const [overview, setOverview] = useState<OverviewResponse | null>(null);
213
+ const [timeline, setTimeline] = useState<TimelinePoint[]>([]);
214
+ const [pages, setPages] = useState<PageStat[]>([]);
215
+ const [clicks, setClicks] = useState<ClickStat[]>([]);
216
+ const [users, setUsers] = useState<UserStat[]>([]);
217
+ const [events, setEvents] = useState<EventRow[]>([]);
218
+ const [eventsTotal, setEventsTotal] = useState(0);
219
+ const [productivity, setProductivity] = useState<ProductivityPayload | null>(null);
220
+ const [autoRefresh, setAutoRefresh] = useState(true);
221
+ const [tab, setTab] = useState("overview");
222
+ const [directory, setDirectory] = useState<
223
+ Array<{ id: string; username: string; kind: string }>
224
+ >([]);
225
+
226
+ const query = useMemo(() => {
227
+ const p = new URLSearchParams({ period, kind });
228
+ if (userId) p.set("userId", userId);
229
+ return p.toString();
230
+ }, [period, kind, userId]);
231
+
232
+ useEffect(() => {
233
+ void (async () => {
234
+ try {
235
+ const res = await fetch("/api/v1/users");
236
+ if (!res.ok) return;
237
+ const body = (await res.json()) as {
238
+ users?: Array<{ id: string; username: string; kind?: string }>;
239
+ };
240
+ setDirectory(
241
+ (body.users || []).map((u) => ({
242
+ id: u.id,
243
+ username: u.username,
244
+ kind: u.kind || "human",
245
+ })),
246
+ );
247
+ } catch {
248
+ /* ignore */
249
+ }
250
+ })();
251
+ }, []);
252
+
253
+ const load = useCallback(async () => {
254
+ setLoading(true);
255
+ setError(null);
256
+ try {
257
+ const [ov, tl, pg, ck, us, ev, prod] = await Promise.all([
258
+ fetch(`/api/v1/admin/analytics/overview?${query}`),
259
+ fetch(`/api/v1/admin/analytics/timeline?${query}`),
260
+ fetch(`/api/v1/admin/analytics/pages?${query}&limit=20`),
261
+ fetch(`/api/v1/admin/analytics/clicks?${query}&limit=20`),
262
+ fetch(`/api/v1/admin/analytics/users?${query}&limit=40`),
263
+ fetch(`/api/v1/admin/analytics/events?${query}&limit=80`),
264
+ fetch(`/api/v1/admin/analytics/productivity?${query}`),
265
+ ]);
266
+ for (const r of [ov, tl, pg, ck, us, ev, prod]) {
267
+ if (!r.ok) {
268
+ const body = (await r.json().catch(() => ({}))) as { error?: string };
269
+ throw new Error(body.error || `HTTP ${r.status}`);
270
+ }
271
+ }
272
+ const ovj = (await ov.json()) as OverviewResponse;
273
+ const tlj = (await tl.json()) as { points: TimelinePoint[] };
274
+ const pgj = (await pg.json()) as { pages: PageStat[] };
275
+ const ckj = (await ck.json()) as { clicks: ClickStat[] };
276
+ const usj = (await us.json()) as { users: UserStat[] };
277
+ const evj = (await ev.json()) as { events: EventRow[]; total: number };
278
+ const prodj = (await prod.json()) as ProductivityPayload;
279
+ setOverview(ovj);
280
+ setTimeline(tlj.points || []);
281
+ setPages(pgj.pages || []);
282
+ setClicks(ckj.clicks || []);
283
+ setUsers(usj.users || []);
284
+ setEvents(evj.events || []);
285
+ setEventsTotal(evj.total ?? 0);
286
+ setProductivity(prodj);
287
+ } catch (e) {
288
+ setError(e instanceof Error ? e.message : "Erreur de chargement");
289
+ } finally {
290
+ setLoading(false);
291
+ }
292
+ }, [query]);
293
+
294
+ useEffect(() => {
295
+ void load();
296
+ }, [load]);
297
+
298
+ useEffect(() => {
299
+ if (!autoRefresh) return;
300
+ const id = window.setInterval(() => void load(), 8000);
301
+ return () => window.clearInterval(id);
302
+ }, [autoRefresh, load]);
303
+
304
+ async function purgeAll() {
305
+ if (!window.confirm("Purger tous les événements analytics ?")) return;
306
+ try {
307
+ const res = await fetch("/api/v1/admin/analytics/events", {
308
+ method: "DELETE",
309
+ headers: { "Content-Type": "application/json" },
310
+ body: JSON.stringify({ all: true }),
311
+ });
312
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
313
+ await load();
314
+ } catch (e) {
315
+ setError(e instanceof Error ? e.message : "Échec purge");
316
+ }
317
+ }
318
+
319
+ const chartData = useMemo(
320
+ () =>
321
+ timeline.map((p) => ({
322
+ ...p,
323
+ label: formatBucket(p.bucket, period),
324
+ timeMin: Math.round((p.timeMs || 0) / 60000),
325
+ })),
326
+ [timeline, period],
327
+ );
328
+
329
+ const maxPageViews = pages[0]?.views || 1;
330
+ const maxClicks = clicks[0]?.clicks || 1;
331
+ const totals = overview?.totals;
332
+
333
+ return (
334
+ <div className="space-y-5">
335
+ {/* Toolbar */}
336
+ <div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-slate-200/80 bg-gradient-to-r from-slate-50 via-white to-sky-50/60 p-3 shadow-sm">
337
+ <div className="flex flex-wrap items-center gap-2">
338
+ <div className="inline-flex rounded-xl bg-white p-1 shadow-sm ring-1 ring-slate-200">
339
+ {PERIODS.map((p) => (
340
+ <button
341
+ key={p.id}
342
+ type="button"
343
+ onClick={() => setPeriod(p.id)}
344
+ className={cn(
345
+ "rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
346
+ period === p.id
347
+ ? "bg-slate-900 text-white shadow-sm"
348
+ : "text-slate-600 hover:bg-slate-100",
349
+ )}
350
+ >
351
+ {p.label}
352
+ </button>
353
+ ))}
354
+ </div>
355
+ <div className="inline-flex rounded-xl bg-white p-1 shadow-sm ring-1 ring-slate-200">
356
+ {KINDS.map((k) => (
357
+ <button
358
+ key={k.id}
359
+ type="button"
360
+ onClick={() => setKind(k.id)}
361
+ className={cn(
362
+ "rounded-lg px-3 py-1.5 text-xs font-medium transition-colors",
363
+ kind === k.id
364
+ ? "bg-sky-600 text-white shadow-sm"
365
+ : "text-slate-600 hover:bg-slate-100",
366
+ )}
367
+ >
368
+ {k.label}
369
+ </button>
370
+ ))}
371
+ </div>
372
+ <div className="flex items-center gap-2">
373
+ <Filter className="h-3.5 w-3.5 text-slate-400" />
374
+ <select
375
+ value={userId}
376
+ onChange={(e) => setUserId(e.target.value)}
377
+ className="h-8 rounded-lg border border-slate-200 bg-white px-2 text-xs text-slate-700 shadow-sm outline-none focus:ring-2 focus:ring-sky-200"
378
+ >
379
+ <option value="">Tous les collaborateurs</option>
380
+ {(directory.length
381
+ ? directory
382
+ : users.map((u) => ({
383
+ id: u.userId,
384
+ username: u.username,
385
+ kind: u.kind,
386
+ }))
387
+ ).map((u) => (
388
+ <option key={u.id} value={u.id}>
389
+ {u.username} ({u.kind})
390
+ </option>
391
+ ))}
392
+ </select>
393
+ </div>
394
+ </div>
395
+ <div className="flex flex-wrap items-center gap-2">
396
+ <label className="flex items-center gap-1.5 text-xs text-slate-600">
397
+ <input
398
+ type="checkbox"
399
+ checked={autoRefresh}
400
+ onChange={(e) => setAutoRefresh(e.target.checked)}
401
+ className="rounded border-slate-300"
402
+ />
403
+ Auto-refresh
404
+ </label>
405
+ <Button
406
+ variant="outline"
407
+ size="sm"
408
+ onClick={() => void load()}
409
+ disabled={loading}
410
+ className="gap-1.5"
411
+ >
412
+ <RefreshCw className={cn("h-3.5 w-3.5", loading && "animate-spin")} />
413
+ Actualiser
414
+ </Button>
415
+ <Button
416
+ variant="outline"
417
+ size="sm"
418
+ onClick={() => void purgeAll()}
419
+ className="gap-1.5 text-rose-600 hover:text-rose-700"
420
+ >
421
+ <Trash2 className="h-3.5 w-3.5" />
422
+ Purger
423
+ </Button>
424
+ </div>
425
+ </div>
426
+
427
+ {error ? (
428
+ <div className="rounded-xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
429
+ {error}
430
+ </div>
431
+ ) : null}
432
+
433
+ {/* KPIs */}
434
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
435
+ <KpiCard
436
+ label="Pages vues"
437
+ value={(totals?.pageViews ?? 0).toLocaleString("fr-FR")}
438
+ hint={`${totals?.sessions ?? 0} sessions`}
439
+ icon={Eye}
440
+ accent="bg-sky-500"
441
+ />
442
+ <KpiCard
443
+ label="Clics"
444
+ value={(totals?.clicks ?? 0).toLocaleString("fr-FR")}
445
+ hint={`${totals?.events ?? 0} événements`}
446
+ icon={MousePointerClick}
447
+ accent="bg-violet-500"
448
+ />
449
+ <KpiCard
450
+ label="Utilisateurs actifs"
451
+ value={(totals?.activeUsers ?? 0).toLocaleString("fr-FR")}
452
+ hint={`${totals?.activeHumans ?? 0} humains · ${totals?.activeAi ?? 0} IA`}
453
+ icon={Users}
454
+ accent="bg-emerald-500"
455
+ />
456
+ <KpiCard
457
+ label="Temps passé"
458
+ value={formatDuration(totals?.totalTimeMs ?? 0)}
459
+ hint={`moy. session ${formatDuration(totals?.avgSessionTimeMs ?? 0)}`}
460
+ icon={Clock3}
461
+ accent="bg-amber-500"
462
+ />
463
+ </div>
464
+
465
+ <Tabs value={tab} onValueChange={setTab} className="space-y-4">
466
+ <TabsList>
467
+ <TabsTrigger value="overview">Vue d&apos;ensemble</TabsTrigger>
468
+ <TabsTrigger value="productivity">Productivité</TabsTrigger>
469
+ <TabsTrigger value="pages">Pages</TabsTrigger>
470
+ <TabsTrigger value="clicks">Clics</TabsTrigger>
471
+ <TabsTrigger value="users">Collaborateurs</TabsTrigger>
472
+ <TabsTrigger value="logs">Journal</TabsTrigger>
473
+ </TabsList>
474
+
475
+ <TabsContent value="productivity">
476
+ <AnalyticsProductivityPanel
477
+ data={productivity}
478
+ selectedUserId={userId}
479
+ onSelectUser={setUserId}
480
+ />
481
+ </TabsContent>
482
+
483
+ <TabsContent value="overview" className="space-y-4">
484
+ <div className="grid gap-4 xl:grid-cols-3">
485
+ <div className="xl:col-span-2 rounded-2xl border border-slate-200/80 bg-white p-4 shadow-sm">
486
+ <div className="mb-3 flex items-center justify-between">
487
+ <div>
488
+ <h2 className="text-sm font-semibold text-slate-900">Activité</h2>
489
+ <p className="text-xs text-slate-500">
490
+ Pages vues, clics et répartition humain / IA
491
+ </p>
492
+ </div>
493
+ <Activity className="h-4 w-4 text-slate-400" />
494
+ </div>
495
+ <div className="h-72 w-full">
496
+ {chartData.length < 1 ? (
497
+ <p className="flex h-full items-center justify-center text-sm text-slate-400">
498
+ Aucune donnée sur cette période
499
+ </p>
500
+ ) : (
501
+ <ResponsiveContainer width="100%" height="100%">
502
+ <AreaChart data={chartData}>
503
+ <defs>
504
+ <linearGradient id="fillViews" x1="0" y1="0" x2="0" y2="1">
505
+ <stop offset="5%" stopColor="#0ea5e9" stopOpacity={0.35} />
506
+ <stop offset="95%" stopColor="#0ea5e9" stopOpacity={0} />
507
+ </linearGradient>
508
+ <linearGradient id="fillClicks" x1="0" y1="0" x2="0" y2="1">
509
+ <stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.3} />
510
+ <stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
511
+ </linearGradient>
512
+ </defs>
513
+ <CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
514
+ <XAxis dataKey="label" tick={{ fontSize: 11 }} stroke="#94a3b8" />
515
+ <YAxis tick={{ fontSize: 11 }} stroke="#94a3b8" width={36} />
516
+ <Tooltip
517
+ contentStyle={{ fontSize: 12, borderRadius: 12 }}
518
+ labelFormatter={(l) => String(l)}
519
+ />
520
+ <Area
521
+ type="monotone"
522
+ dataKey="pageViews"
523
+ name="Pages"
524
+ stroke="#0ea5e9"
525
+ fill="url(#fillViews)"
526
+ strokeWidth={2}
527
+ />
528
+ <Area
529
+ type="monotone"
530
+ dataKey="clicks"
531
+ name="Clics"
532
+ stroke="#8b5cf6"
533
+ fill="url(#fillClicks)"
534
+ strokeWidth={2}
535
+ />
536
+ </AreaChart>
537
+ </ResponsiveContainer>
538
+ )}
539
+ </div>
540
+ </div>
541
+
542
+ <div className="rounded-2xl border border-slate-200/80 bg-white p-4 shadow-sm">
543
+ <h2 className="text-sm font-semibold text-slate-900">Répartition</h2>
544
+ <p className="mb-4 text-xs text-slate-500">Événements par type d&apos;acteur</p>
545
+ <div className="space-y-3">
546
+ {(overview?.byKind || []).length === 0 ? (
547
+ <p className="text-sm text-slate-400">Pas encore de données</p>
548
+ ) : (
549
+ overview?.byKind.map((row) => {
550
+ const total = totals?.events || 1;
551
+ const pct = Math.round((row.events / total) * 100);
552
+ return (
553
+ <div key={row.kind} className="space-y-1.5">
554
+ <div className="flex items-center justify-between gap-2">
555
+ <div className="flex items-center gap-2">
556
+ {kindBadge(row.kind)}
557
+ <span className="text-xs text-slate-500">
558
+ {row.users} user{row.users > 1 ? "s" : ""}
559
+ </span>
560
+ </div>
561
+ <span className="text-sm font-semibold tabular-nums text-slate-800">
562
+ {row.events.toLocaleString("fr-FR")}
563
+ <span className="ml-1 text-xs font-normal text-slate-400">
564
+ ({pct}%)
565
+ </span>
566
+ </span>
567
+ </div>
568
+ <RankBar value={row.events} max={total} />
569
+ <p className="text-[11px] text-slate-400">
570
+ Temps {formatDuration(row.timeMs)}
571
+ </p>
572
+ </div>
573
+ );
574
+ })
575
+ )}
576
+ </div>
577
+ </div>
578
+ </div>
579
+
580
+ <div className="grid gap-4 lg:grid-cols-2">
581
+ <TopList
582
+ title="Pages les plus vues"
583
+ empty="Aucune page trackée"
584
+ rows={pages.slice(0, 8).map((p) => ({
585
+ key: p.path,
586
+ primary: p.path,
587
+ secondary: `${p.uniqueUsers} users · ${formatDuration(p.avgTimeMs)} moy.`,
588
+ value: p.views,
589
+ max: maxPageViews,
590
+ }))}
591
+ />
592
+ <TopList
593
+ title="Boutons les plus cliqués"
594
+ empty="Aucun clic tracké"
595
+ rows={clicks.slice(0, 8).map((c) => ({
596
+ key: c.label,
597
+ primary: c.label,
598
+ secondary: `${c.uniqueUsers} users · ${c.paths} pages`,
599
+ value: c.clicks,
600
+ max: maxClicks,
601
+ }))}
602
+ />
603
+ </div>
604
+ </TabsContent>
605
+
606
+ <TabsContent value="pages">
607
+ <DataTable
608
+ headers={["Page", "Vues", "Users", "Temps moy.", "Temps total"]}
609
+ empty="Aucune page"
610
+ rows={pages.map((p) => [
611
+ <span key="p" className="font-medium text-slate-800">
612
+ {p.path}
613
+ </span>,
614
+ p.views.toLocaleString("fr-FR"),
615
+ p.uniqueUsers.toLocaleString("fr-FR"),
616
+ formatDuration(p.avgTimeMs),
617
+ formatDuration(p.totalTimeMs),
618
+ ])}
619
+ />
620
+ </TabsContent>
621
+
622
+ <TabsContent value="clicks">
623
+ <DataTable
624
+ headers={["Élément", "Clics", "Users", "Pages"]}
625
+ empty="Aucun clic"
626
+ rows={clicks.map((c) => [
627
+ <span key="l" className="font-medium text-slate-800">
628
+ {c.label}
629
+ </span>,
630
+ c.clicks.toLocaleString("fr-FR"),
631
+ c.uniqueUsers.toLocaleString("fr-FR"),
632
+ c.paths.toLocaleString("fr-FR"),
633
+ ])}
634
+ />
635
+ </TabsContent>
636
+
637
+ <TabsContent value="users">
638
+ <DataTable
639
+ headers={[
640
+ "Collaborateur",
641
+ "Type",
642
+ "Événements",
643
+ "Pages",
644
+ "Clics",
645
+ "Sessions",
646
+ "Temps",
647
+ "Dernière activité",
648
+ ]}
649
+ empty="Aucun utilisateur"
650
+ rows={users.map((u) => [
651
+ <button
652
+ key="u"
653
+ type="button"
654
+ className="text-left font-medium text-sky-700 hover:underline"
655
+ onClick={() => setUserId(u.userId === userId ? "" : u.userId)}
656
+ >
657
+ {u.username}
658
+ </button>,
659
+ kindBadge(u.kind),
660
+ u.events.toLocaleString("fr-FR"),
661
+ u.pageViews.toLocaleString("fr-FR"),
662
+ u.clicks.toLocaleString("fr-FR"),
663
+ u.sessions.toLocaleString("fr-FR"),
664
+ formatDuration(u.timeMs),
665
+ u.lastSeen ? formatTs(u.lastSeen) : "—",
666
+ ])}
667
+ />
668
+ </TabsContent>
669
+
670
+ <TabsContent value="logs">
671
+ <div className="mb-2 flex items-center justify-between text-xs text-slate-500">
672
+ <span>
673
+ {eventsTotal.toLocaleString("fr-FR")} événement
674
+ {eventsTotal > 1 ? "s" : ""} — affichage des {events.length} plus récents
675
+ </span>
676
+ </div>
677
+ <div className="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
678
+ <div className="max-h-[560px] overflow-auto">
679
+ <table className="w-full text-left text-sm">
680
+ <thead className="sticky top-0 z-10 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
681
+ <tr>
682
+ <th className="px-3 py-2 font-medium">Quand</th>
683
+ <th className="px-3 py-2 font-medium">Type</th>
684
+ <th className="px-3 py-2 font-medium">Acteur</th>
685
+ <th className="px-3 py-2 font-medium">Label</th>
686
+ <th className="px-3 py-2 font-medium">Path</th>
687
+ <th className="px-3 py-2 font-medium">Durée</th>
688
+ </tr>
689
+ </thead>
690
+ <tbody>
691
+ {events.length === 0 ? (
692
+ <tr>
693
+ <td colSpan={6} className="px-3 py-10 text-center text-slate-400">
694
+ Journal vide — naviguez dans l&apos;app pour générer des événements
695
+ </td>
696
+ </tr>
697
+ ) : (
698
+ events.map((e) => (
699
+ <tr
700
+ key={e.id}
701
+ className="border-t border-slate-100 hover:bg-slate-50/80"
702
+ >
703
+ <td className="whitespace-nowrap px-3 py-2 text-xs tabular-nums text-slate-500">
704
+ {formatTs(e.created_at)}
705
+ </td>
706
+ <td className="px-3 py-2">
707
+ <code className="rounded bg-slate-100 px-1.5 py-0.5 text-[11px] text-slate-700">
708
+ {e.event_type}
709
+ </code>
710
+ </td>
711
+ <td className="px-3 py-2">
712
+ <div className="flex items-center gap-2">
713
+ {kindBadge(e.user_kind)}
714
+ <span className="text-xs text-slate-700">
715
+ {e.username || "—"}
716
+ </span>
717
+ </div>
718
+ </td>
719
+ <td className="max-w-[220px] truncate px-3 py-2 text-slate-800">
720
+ {e.label || "—"}
721
+ </td>
722
+ <td className="max-w-[180px] truncate px-3 py-2 text-xs text-slate-500">
723
+ {e.path || "—"}
724
+ </td>
725
+ <td className="whitespace-nowrap px-3 py-2 text-xs tabular-nums text-slate-500">
726
+ {e.duration_ms != null ? formatDuration(e.duration_ms) : "—"}
727
+ </td>
728
+ </tr>
729
+ ))
730
+ )}
731
+ </tbody>
732
+ </table>
733
+ </div>
734
+ </div>
735
+ </TabsContent>
736
+ </Tabs>
737
+ </div>
738
+ );
739
+ }
740
+
741
+ function TopList({
742
+ title,
743
+ empty,
744
+ rows,
745
+ }: {
746
+ title: string;
747
+ empty: string;
748
+ rows: Array<{
749
+ key: string;
750
+ primary: string;
751
+ secondary: string;
752
+ value: number;
753
+ max: number;
754
+ }>;
755
+ }) {
756
+ return (
757
+ <div className="rounded-2xl border border-slate-200/80 bg-white p-4 shadow-sm">
758
+ <h2 className="text-sm font-semibold text-slate-900">{title}</h2>
759
+ <div className="mt-3 space-y-3">
760
+ {rows.length === 0 ? (
761
+ <p className="py-6 text-center text-sm text-slate-400">{empty}</p>
762
+ ) : (
763
+ rows.map((r, i) => (
764
+ <div key={r.key} className="space-y-1">
765
+ <div className="flex items-start justify-between gap-3">
766
+ <div className="min-w-0">
767
+ <p className="truncate text-sm font-medium text-slate-800">
768
+ <span className="mr-2 text-xs text-slate-400">{i + 1}.</span>
769
+ {r.primary}
770
+ </p>
771
+ <p className="truncate text-[11px] text-slate-400">{r.secondary}</p>
772
+ </div>
773
+ <span className="shrink-0 text-sm font-semibold tabular-nums text-slate-900">
774
+ {r.value.toLocaleString("fr-FR")}
775
+ </span>
776
+ </div>
777
+ <RankBar value={r.value} max={r.max} />
778
+ </div>
779
+ ))
780
+ )}
781
+ </div>
782
+ </div>
783
+ );
784
+ }
785
+
786
+ function DataTable({
787
+ headers,
788
+ rows,
789
+ empty,
790
+ }: {
791
+ headers: string[];
792
+ rows: React.ReactNode[][];
793
+ empty: string;
794
+ }) {
795
+ return (
796
+ <div className="overflow-hidden rounded-2xl border border-slate-200/80 bg-white shadow-sm">
797
+ <div className="overflow-auto">
798
+ <table className="w-full text-left text-sm">
799
+ <thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
800
+ <tr>
801
+ {headers.map((h) => (
802
+ <th key={h} className="px-3 py-2.5 font-medium">
803
+ {h}
804
+ </th>
805
+ ))}
806
+ </tr>
807
+ </thead>
808
+ <tbody>
809
+ {rows.length === 0 ? (
810
+ <tr>
811
+ <td
812
+ colSpan={headers.length}
813
+ className="px-3 py-10 text-center text-slate-400"
814
+ >
815
+ {empty}
816
+ </td>
817
+ </tr>
818
+ ) : (
819
+ rows.map((row, i) => (
820
+ <tr key={i} className="border-t border-slate-100 hover:bg-slate-50/70">
821
+ {row.map((cell, j) => (
822
+ <td key={j} className="px-3 py-2.5 align-middle text-slate-700">
823
+ {cell}
824
+ </td>
825
+ ))}
826
+ </tr>
827
+ ))
828
+ )}
829
+ </tbody>
830
+ </table>
831
+ </div>
832
+ </div>
833
+ );
834
+ }