@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.
- package/README.md +328 -0
- package/dist/.creezio-src-hash.json +4 -0
- package/dist/api-endpoints/http-routes.d.ts +11 -0
- package/dist/api-endpoints/http-routes.d.ts.map +1 -0
- package/dist/api-endpoints/http-routes.js +11 -0
- package/dist/api-endpoints/http-routes.js.map +1 -0
- package/dist/api-endpoints/index.d.ts +4 -0
- package/dist/api-endpoints/index.d.ts.map +1 -0
- package/dist/api-endpoints/index.js +3 -0
- package/dist/api-endpoints/index.js.map +1 -0
- package/dist/api-endpoints/registry.d.ts +66 -0
- package/dist/api-endpoints/registry.d.ts.map +1 -0
- package/dist/api-endpoints/registry.js +102 -0
- package/dist/api-endpoints/registry.js.map +1 -0
- package/dist/api-mount.d.ts +7 -0
- package/dist/api-mount.d.ts.map +1 -0
- package/dist/api-mount.js +114 -0
- package/dist/api-mount.js.map +1 -0
- package/dist/helpers.d.ts +23 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/helpers.js +36 -0
- package/dist/helpers.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/memory-store.d.ts +3 -0
- package/dist/memory-store.d.ts.map +1 -0
- package/dist/memory-store.js +113 -0
- package/dist/memory-store.js.map +1 -0
- package/dist/ops/emit.d.ts +8 -0
- package/dist/ops/emit.d.ts.map +1 -0
- package/dist/ops/emit.js +15 -0
- package/dist/ops/emit.js.map +1 -0
- package/dist/ops/fleet-activity.d.ts +50 -0
- package/dist/ops/fleet-activity.d.ts.map +1 -0
- package/dist/ops/fleet-activity.js +123 -0
- package/dist/ops/fleet-activity.js.map +1 -0
- package/dist/ops/fleet-agent.d.ts +102 -0
- package/dist/ops/fleet-agent.d.ts.map +1 -0
- package/dist/ops/fleet-agent.js +352 -0
- package/dist/ops/fleet-agent.js.map +1 -0
- package/dist/ops/fleet-samples.d.ts +33 -0
- package/dist/ops/fleet-samples.d.ts.map +1 -0
- package/dist/ops/fleet-samples.js +223 -0
- package/dist/ops/fleet-samples.js.map +1 -0
- package/dist/ops/journal.d.ts +56 -0
- package/dist/ops/journal.d.ts.map +1 -0
- package/dist/ops/journal.js +307 -0
- package/dist/ops/journal.js.map +1 -0
- package/dist/ops/rules.d.ts +16 -0
- package/dist/ops/rules.d.ts.map +1 -0
- package/dist/ops/rules.js +98 -0
- package/dist/ops/rules.js.map +1 -0
- package/dist/ops/types.d.ts +69 -0
- package/dist/ops/types.d.ts.map +1 -0
- package/dist/ops/types.js +119 -0
- package/dist/ops/types.js.map +1 -0
- package/dist/request-logs/config.d.ts +19 -0
- package/dist/request-logs/config.d.ts.map +1 -0
- package/dist/request-logs/config.js +36 -0
- package/dist/request-logs/config.js.map +1 -0
- package/dist/request-logs/http-routes.d.ts +8 -0
- package/dist/request-logs/http-routes.d.ts.map +1 -0
- package/dist/request-logs/http-routes.js +29 -0
- package/dist/request-logs/http-routes.js.map +1 -0
- package/dist/request-logs/index.d.ts +6 -0
- package/dist/request-logs/index.d.ts.map +1 -0
- package/dist/request-logs/index.js +5 -0
- package/dist/request-logs/index.js.map +1 -0
- package/dist/request-logs/middleware.d.ts +11 -0
- package/dist/request-logs/middleware.d.ts.map +1 -0
- package/dist/request-logs/middleware.js +198 -0
- package/dist/request-logs/middleware.js.map +1 -0
- package/dist/request-logs/request-logs.d.ts +60 -0
- package/dist/request-logs/request-logs.d.ts.map +1 -0
- package/dist/request-logs/request-logs.js +260 -0
- package/dist/request-logs/request-logs.js.map +1 -0
- package/dist/schema.d.ts +3 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +21 -0
- package/dist/schema.js.map +1 -0
- package/dist/sqlite-driver.d.ts +16 -0
- package/dist/sqlite-driver.d.ts.map +1 -0
- package/dist/sqlite-driver.js +15 -0
- package/dist/sqlite-driver.js.map +1 -0
- package/dist/sqlite-store.d.ts +12 -0
- package/dist/sqlite-store.d.ts.map +1 -0
- package/dist/sqlite-store.js +176 -0
- package/dist/sqlite-store.js.map +1 -0
- package/dist/types.d.ts +67 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +9 -0
- package/dist/types.js.map +1 -0
- package/dist/usage/adapters.d.ts +31 -0
- package/dist/usage/adapters.d.ts.map +1 -0
- package/dist/usage/adapters.js +37 -0
- package/dist/usage/adapters.js.map +1 -0
- package/dist/usage/http-routes.d.ts +23 -0
- package/dist/usage/http-routes.d.ts.map +1 -0
- package/dist/usage/http-routes.js +150 -0
- package/dist/usage/http-routes.js.map +1 -0
- package/dist/usage/index.d.ts +11 -0
- package/dist/usage/index.d.ts.map +1 -0
- package/dist/usage/index.js +7 -0
- package/dist/usage/index.js.map +1 -0
- package/dist/usage/ui-brand.d.ts +23 -0
- package/dist/usage/ui-brand.d.ts.map +1 -0
- package/dist/usage/ui-brand.js +18 -0
- package/dist/usage/ui-brand.js.map +1 -0
- package/dist/usage/usage-analytics-productivity.d.ts +73 -0
- package/dist/usage/usage-analytics-productivity.d.ts.map +1 -0
- package/dist/usage/usage-analytics-productivity.js +317 -0
- package/dist/usage/usage-analytics-productivity.js.map +1 -0
- package/dist/usage/usage-analytics-shared.d.ts +5 -0
- package/dist/usage/usage-analytics-shared.d.ts.map +1 -0
- package/dist/usage/usage-analytics-shared.js +20 -0
- package/dist/usage/usage-analytics-shared.js.map +1 -0
- package/dist/usage/usage-analytics.d.ts +127 -0
- package/dist/usage/usage-analytics.d.ts.map +1 -0
- package/dist/usage/usage-analytics.js +338 -0
- package/dist/usage/usage-analytics.js.map +1 -0
- package/dist-cjs/api-endpoints/http-routes.js +14 -0
- package/dist-cjs/api-endpoints/http-routes.js.map +1 -0
- package/dist-cjs/api-endpoints/index.js +10 -0
- package/dist-cjs/api-endpoints/index.js.map +1 -0
- package/dist-cjs/api-endpoints/registry.js +107 -0
- package/dist-cjs/api-endpoints/registry.js.map +1 -0
- package/dist-cjs/api-mount.js +117 -0
- package/dist-cjs/api-mount.js.map +1 -0
- package/dist-cjs/helpers.js +41 -0
- package/dist-cjs/helpers.js.map +1 -0
- package/dist-cjs/index.js +118 -0
- package/dist-cjs/index.js.map +1 -0
- package/dist-cjs/memory-store.js +119 -0
- package/dist-cjs/memory-store.js.map +1 -0
- package/dist-cjs/ops/emit.js +18 -0
- package/dist-cjs/ops/emit.js.map +1 -0
- package/dist-cjs/ops/fleet-activity.js +131 -0
- package/dist-cjs/ops/fleet-activity.js.map +1 -0
- package/dist-cjs/ops/fleet-agent.js +358 -0
- package/dist-cjs/ops/fleet-agent.js.map +1 -0
- package/dist-cjs/ops/fleet-samples.js +229 -0
- package/dist-cjs/ops/fleet-samples.js.map +1 -0
- package/dist-cjs/ops/journal.js +326 -0
- package/dist-cjs/ops/journal.js.map +1 -0
- package/dist-cjs/ops/rules.js +102 -0
- package/dist-cjs/ops/rules.js.map +1 -0
- package/dist-cjs/ops/types.js +126 -0
- package/dist-cjs/ops/types.js.map +1 -0
- package/dist-cjs/package.json +3 -0
- package/dist-cjs/request-logs/config.js +42 -0
- package/dist-cjs/request-logs/config.js.map +1 -0
- package/dist-cjs/request-logs/http-routes.js +32 -0
- package/dist-cjs/request-logs/http-routes.js.map +1 -0
- package/dist-cjs/request-logs/index.js +27 -0
- package/dist-cjs/request-logs/index.js.map +1 -0
- package/dist-cjs/request-logs/middleware.js +203 -0
- package/dist-cjs/request-logs/middleware.js.map +1 -0
- package/dist-cjs/request-logs/request-logs.js +277 -0
- package/dist-cjs/request-logs/request-logs.js.map +1 -0
- package/dist-cjs/schema.js +24 -0
- package/dist-cjs/schema.js.map +1 -0
- package/dist-cjs/sqlite-driver.js +18 -0
- package/dist-cjs/sqlite-driver.js.map +1 -0
- package/dist-cjs/sqlite-store.js +182 -0
- package/dist-cjs/sqlite-store.js.map +1 -0
- package/dist-cjs/types.js +12 -0
- package/dist-cjs/types.js.map +1 -0
- package/dist-cjs/usage/adapters.js +47 -0
- package/dist-cjs/usage/adapters.js.map +1 -0
- package/dist-cjs/usage/http-routes.js +154 -0
- package/dist-cjs/usage/http-routes.js.map +1 -0
- package/dist-cjs/usage/index.js +34 -0
- package/dist-cjs/usage/index.js.map +1 -0
- package/dist-cjs/usage/ui-brand.js +23 -0
- package/dist-cjs/usage/ui-brand.js.map +1 -0
- package/dist-cjs/usage/usage-analytics-productivity.js +321 -0
- package/dist-cjs/usage/usage-analytics-productivity.js.map +1 -0
- package/dist-cjs/usage/usage-analytics-shared.js +23 -0
- package/dist-cjs/usage/usage-analytics-shared.js.map +1 -0
- package/dist-cjs/usage/usage-analytics.js +353 -0
- package/dist-cjs/usage/usage-analytics.js.map +1 -0
- package/fleet-collector/AGENTS.md +7 -0
- package/fleet-collector/README.md +83 -0
- package/fleet-collector/configure-fleet-npm.sh +108 -0
- package/fleet-collector/env.mjs +174 -0
- package/fleet-collector/ops-api.mjs +455 -0
- package/fleet-collector/public/admin.html +696 -0
- package/fleet-collector/public/index.html +941 -0
- package/fleet-collector/server.mjs +564 -0
- package/fleet-collector/test-fleet-collector.mjs +413 -0
- package/fleet-collector/test-server-admin.mjs +475 -0
- package/package.json +109 -0
- package/scripts/test-api-endpoints-registry.mjs +60 -0
- package/ui/analytics-client.tsx +834 -0
- package/ui/analytics-productivity-panel.tsx +513 -0
- package/ui/api-endpoints-client.tsx +228 -0
- package/ui/index.ts +39 -0
- package/ui/primitives/badge.tsx +37 -0
- package/ui/primitives/button.tsx +44 -0
- package/ui/primitives/card.tsx +44 -0
- package/ui/primitives/cn.ts +6 -0
- package/ui/primitives/input.tsx +19 -0
- package/ui/primitives/tabs.tsx +51 -0
- package/ui/request-logs-client.tsx +327 -0
- package/ui/session-usage-analytics-provider.tsx +28 -0
- package/ui/usage-analytics-client.ts +479 -0
- 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'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'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'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
|
+
}
|