@tekkare/romulus 0.1.244 → 0.1.246
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/dist/module.json +1 -1
- package/layer/app/app.config.ts +6 -0
- package/layer/app/components/OipShell.vue +4 -0
- package/layer/app/components/layout/tool/views.vue +9 -1
- package/layer/app/components/panels/OipPanelProjects.vue +8 -1
- package/layer/app/components/panels/OipPanelStatistics.vue +447 -0
- package/layer/app/types/stats.ts +49 -0
- package/layer/app/utils/oipRail.ts +29 -3
- package/layer/nuxt.config.ts +9 -0
- package/layer/server/api/oip/stats/users.get.ts +80 -0
- package/layer/server/api/oip/stats.get.ts +88 -0
- package/layer/server/utils/oipPosthog.ts +134 -0
- package/layer/server/utils/oipWorkspaces.ts +51 -0
- package/package.json +1 -1
package/dist/module.json
CHANGED
package/layer/app/app.config.ts
CHANGED
|
@@ -55,6 +55,12 @@ export default defineAppConfig({
|
|
|
55
55
|
Metadata: {
|
|
56
56
|
endpoint: "/api/oip/metadata",
|
|
57
57
|
},
|
|
58
|
+
Statistics: {
|
|
59
|
+
/** Audience PostHog de l'ecran et de l'app (GET ?path=&internal=). */
|
|
60
|
+
endpoint: "/api/oip/stats",
|
|
61
|
+
/** Classement des utilisateurs, pagine (GET ?period=&scope=&offset=&limit=). */
|
|
62
|
+
usersEndpoint: "/api/oip/stats/users",
|
|
63
|
+
},
|
|
58
64
|
Sources: {
|
|
59
65
|
/** Fiches Directus des sources (POST lecture, PATCH correction). */
|
|
60
66
|
endpoint: "/api/oip/sources",
|
|
@@ -32,6 +32,7 @@ import OipPanelNotes from "./panels/OipPanelNotes.vue";
|
|
|
32
32
|
import OipPanelProjects from "./panels/OipPanelProjects.vue";
|
|
33
33
|
import OipPanelSources from "./panels/OipPanelSources.vue";
|
|
34
34
|
import OipPanelSourceTables from "./panels/OipPanelSourceTables.vue";
|
|
35
|
+
import OipPanelStatistics from "./panels/OipPanelStatistics.vue";
|
|
35
36
|
import OipPanelTopics from "./panels/OipPanelTopics.vue";
|
|
36
37
|
import OipPanelTutorials from "./panels/OipPanelTutorials.vue";
|
|
37
38
|
|
|
@@ -46,6 +47,7 @@ const LAYER_PANELS: Partial<Record<OipRailTileKey, Component>> = {
|
|
|
46
47
|
Projects: OipPanelProjects,
|
|
47
48
|
Sources: OipPanelSources,
|
|
48
49
|
SourcesTables: OipPanelSourceTables,
|
|
50
|
+
Statistics: OipPanelStatistics,
|
|
49
51
|
Topics: OipPanelTopics,
|
|
50
52
|
Tutorials: OipPanelTutorials,
|
|
51
53
|
};
|
|
@@ -304,6 +306,7 @@ const baseTiles = computed<(OipRailTile & { ownCount?: boolean })[]>(() => {
|
|
|
304
306
|
label: en.value ? entry.label.en : entry.label.fr,
|
|
305
307
|
icon: entry.icon,
|
|
306
308
|
ownCount: entry.ownCount,
|
|
309
|
+
width: entry.width,
|
|
307
310
|
...extra,
|
|
308
311
|
disabled: !state || !!extra.disabled,
|
|
309
312
|
};
|
|
@@ -563,6 +566,7 @@ const tekkareLogo = tekkareLogoRaw.replaceAll('fill="#2100AD"', 'fill="currentCo
|
|
|
563
566
|
:app-key="appKey"
|
|
564
567
|
:super-admin="superAdmin"
|
|
565
568
|
v-bind="panelProps[openKey as OipRailTileKey] ?? {}"
|
|
569
|
+
@close="tool = null"
|
|
566
570
|
/>
|
|
567
571
|
<slot v-else name="tool" :active="openKey" />
|
|
568
572
|
<!-- Complement de l'app sous le panneau ouvert, sans le remplacer. -->
|
|
@@ -26,6 +26,9 @@ import { sameViewPage } from "../../../utils/viewPages";
|
|
|
26
26
|
|
|
27
27
|
const { locale } = useI18n();
|
|
28
28
|
const route = useRoute();
|
|
29
|
+
// Une vue activee referme le panneau : l'ecran filtre redevient visible.
|
|
30
|
+
const emit = defineEmits<{ close: [] }>();
|
|
31
|
+
|
|
29
32
|
const views = useViews();
|
|
30
33
|
const brandSync = useAlertBrandSync();
|
|
31
34
|
const alerts = useAlerts();
|
|
@@ -376,6 +379,11 @@ function apply(id: string) {
|
|
|
376
379
|
}
|
|
377
380
|
|
|
378
381
|
|
|
382
|
+
function activate(id: string) {
|
|
383
|
+
apply(id);
|
|
384
|
+
emit("close");
|
|
385
|
+
}
|
|
386
|
+
|
|
379
387
|
/**
|
|
380
388
|
* « Nouvelle vue » : meme geste que celui de la pastille. L'ecran repart
|
|
381
389
|
* vierge (cf. `resetAll`), puis la page ouvre son tiroir de selection quand
|
|
@@ -681,7 +689,7 @@ async function confirmEdit() {
|
|
|
681
689
|
:lang="locale === 'en' ? 'en' : 'fr'"
|
|
682
690
|
@save-current="openSave()"
|
|
683
691
|
@reset="startNew"
|
|
684
|
-
@activate="onTemplates ? start($event) :
|
|
692
|
+
@activate="onTemplates ? start($event) : activate($event)"
|
|
685
693
|
@star="star"
|
|
686
694
|
@rename="rename"
|
|
687
695
|
@action="onAction"
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
:creating="creating"
|
|
23
23
|
:all-to="allTo"
|
|
24
24
|
:lang="locale === 'en' ? 'en' : 'fr'"
|
|
25
|
-
@activate="
|
|
25
|
+
@activate="activate"
|
|
26
26
|
@open="emit('open', $event)"
|
|
27
27
|
@pin="emit('pin', $event)"
|
|
28
28
|
@create="onCreate"
|
|
@@ -75,6 +75,8 @@ const emit = defineEmits<{
|
|
|
75
75
|
pin: [projectId: string];
|
|
76
76
|
/** Au montage : l'app restaure le projet actif et charge la liste. */
|
|
77
77
|
mounted: [];
|
|
78
|
+
/** Projet active : le cadre referme le panneau, l'ecran redevient visible. */
|
|
79
|
+
close: [];
|
|
78
80
|
}>();
|
|
79
81
|
|
|
80
82
|
const { locale } = useI18n();
|
|
@@ -90,5 +92,10 @@ async function onCreate(title: string) {
|
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
function activate(id: string) {
|
|
96
|
+
emit("activate", id);
|
|
97
|
+
emit("close");
|
|
98
|
+
}
|
|
99
|
+
|
|
93
100
|
onMounted(() => emit("mounted"));
|
|
94
101
|
</script>
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Le volet « Statistiques » du rail : l'audience PostHog de l'écran ouvert et
|
|
3
|
+
de toute l'app, aujourd'hui et sur 7, 30 et 90 jours.
|
|
4
|
+
|
|
5
|
+
Personnes, sessions et vues de page, puis les pages les plus vues et les
|
|
6
|
+
utilisateurs les plus actifs sur la période choisie (dix par dix). Les comptes @tekkare.com sont écartés par défaut :
|
|
7
|
+
sans eux, l'équipe qui recette l'app gonflerait chaque chiffre.
|
|
8
|
+
|
|
9
|
+
Réservé aux superadmins, comme les métadonnées : la tuile n'est posée par le
|
|
10
|
+
cadre que pour eux, et la route serveur revalide le droit.
|
|
11
|
+
-->
|
|
12
|
+
<template>
|
|
13
|
+
<div class="toolbar_tab_container stats_panel">
|
|
14
|
+
<div class="stats_controls">
|
|
15
|
+
<div class="stats_switches">
|
|
16
|
+
<RmSegmented
|
|
17
|
+
v-model="scope"
|
|
18
|
+
:options="scopeOptions"
|
|
19
|
+
size="sm"
|
|
20
|
+
variant="soft"
|
|
21
|
+
:aria-label="isFr ? 'Périmètre' : 'Scope'"
|
|
22
|
+
/>
|
|
23
|
+
<RmSegmented
|
|
24
|
+
v-model="period"
|
|
25
|
+
:options="periodOptions"
|
|
26
|
+
size="sm"
|
|
27
|
+
variant="soft"
|
|
28
|
+
class="stats_periods"
|
|
29
|
+
:aria-label="isFr ? 'Période' : 'Period'"
|
|
30
|
+
/>
|
|
31
|
+
</div>
|
|
32
|
+
<label class="stats_internal">
|
|
33
|
+
<RmToggle v-model="internal" :label="isFr ? 'Inclure Tekkare' : 'Include Tekkare'" />
|
|
34
|
+
<span>{{ isFr ? "Inclure les comptes Tekkare" : "Include Tekkare accounts" }}</span>
|
|
35
|
+
</label>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div v-if="pending && !data" class="stats_loading">
|
|
39
|
+
<RmSkeleton v-for="i in 3" :key="i" width="100%" :height="88" radius="8px" />
|
|
40
|
+
</div>
|
|
41
|
+
|
|
42
|
+
<p v-else-if="error" class="stats_error">
|
|
43
|
+
{{ isFr ? "Statistiques illisibles : " : "Could not read statistics: " }}{{ errorMessage }}
|
|
44
|
+
</p>
|
|
45
|
+
|
|
46
|
+
<template v-else-if="data">
|
|
47
|
+
<section class="stats_section">
|
|
48
|
+
<p v-if="scope === 'page'" class="stats_section_title">
|
|
49
|
+
{{ isFr ? "Cette page" : "This page" }}
|
|
50
|
+
<span v-if="labelOf(data.path) !== data.path" class="stats_path_label">{{ labelOf(data.path) }}</span>
|
|
51
|
+
<code class="stats_path">{{ data.path }}</code>
|
|
52
|
+
</p>
|
|
53
|
+
<p v-else class="stats_section_title">{{ isFr ? "Toute l'app" : "Whole app" }}</p>
|
|
54
|
+
<div class="stats_kpis">
|
|
55
|
+
<RmKpi v-for="k in kpis(data[scope][period])" :key="k.label" :label="k.label" :value="k.value" />
|
|
56
|
+
</div>
|
|
57
|
+
</section>
|
|
58
|
+
|
|
59
|
+
<section class="stats_section">
|
|
60
|
+
<p class="stats_section_title">{{ isFr ? "Utilisateurs les plus actifs" : "Most active users" }}</p>
|
|
61
|
+
<p v-if="usersError" class="stats_error">{{ usersError }}</p>
|
|
62
|
+
<p v-else-if="!usersLoading && !users.length" class="stats_empty">
|
|
63
|
+
{{ isFr ? "Aucun utilisateur sur la période." : "No users in this period." }}
|
|
64
|
+
</p>
|
|
65
|
+
<table v-if="users.length" class="stats_table">
|
|
66
|
+
<thead>
|
|
67
|
+
<tr>
|
|
68
|
+
<th class="stats_rank">#</th>
|
|
69
|
+
<th>{{ isFr ? "Utilisateur" : "User" }}</th>
|
|
70
|
+
<th>Workspace</th>
|
|
71
|
+
<th class="stats_num">{{ isFr ? "Pages vues" : "Page views" }}</th>
|
|
72
|
+
<th class="stats_num">{{ isFr ? "Visites" : "Visits" }}</th>
|
|
73
|
+
<th class="stats_num">
|
|
74
|
+
<RmTooltip :text="screensHint" dir="bottom" max-width="300px" full>
|
|
75
|
+
<span class="stats_hint">{{ isFr ? "Écrans" : "Screens" }}</span>
|
|
76
|
+
</RmTooltip>
|
|
77
|
+
</th>
|
|
78
|
+
<th class="stats_num">{{ isFr ? "Dernière vue" : "Last seen" }}</th>
|
|
79
|
+
</tr>
|
|
80
|
+
</thead>
|
|
81
|
+
<tbody>
|
|
82
|
+
<tr v-for="(u, i) in users" :key="u.email || i">
|
|
83
|
+
<td class="stats_rank">{{ i + 1 }}</td>
|
|
84
|
+
<td>
|
|
85
|
+
<div class="stats_user">
|
|
86
|
+
<span v-if="u.name" class="stats_user_name">{{ u.name }}</span>
|
|
87
|
+
<span class="stats_user_email">{{ u.email || (isFr ? "anonyme" : "anonymous") }}</span>
|
|
88
|
+
</div>
|
|
89
|
+
</td>
|
|
90
|
+
<td class="stats_workspace">{{ u.workspace }}</td>
|
|
91
|
+
<td class="stats_num">{{ rmNumber(u.pageviews) }}</td>
|
|
92
|
+
<td class="stats_num">{{ rmNumber(u.sessions) }}</td>
|
|
93
|
+
<td class="stats_num">{{ rmNumber(u.pages) }}</td>
|
|
94
|
+
<td class="stats_num">{{ seen(u.lastSeen) }}</td>
|
|
95
|
+
</tr>
|
|
96
|
+
</tbody>
|
|
97
|
+
</table>
|
|
98
|
+
<div v-if="usersLoading" class="stats_loading">
|
|
99
|
+
<RmSkeleton v-for="i in 3" :key="i" width="100%" :height="28" radius="6px" />
|
|
100
|
+
</div>
|
|
101
|
+
<RmButton
|
|
102
|
+
v-else-if="usersHasMore"
|
|
103
|
+
variant="secondary"
|
|
104
|
+
size="sm"
|
|
105
|
+
class="stats_more"
|
|
106
|
+
@click="loadUsers()"
|
|
107
|
+
>
|
|
108
|
+
{{ isFr ? "Charger plus" : "Load more" }}
|
|
109
|
+
</RmButton>
|
|
110
|
+
</section>
|
|
111
|
+
|
|
112
|
+
<!-- Le classement des pages n'a de sens qu'a l'echelle de l'app. -->
|
|
113
|
+
<section v-if="scope === 'app'" class="stats_section">
|
|
114
|
+
<p class="stats_section_title">{{ isFr ? "Pages les plus vues" : "Most viewed pages" }}</p>
|
|
115
|
+
<p v-if="!topPages.length" class="stats_empty">
|
|
116
|
+
{{ isFr ? "Aucune vue sur la période." : "No views in this period." }}
|
|
117
|
+
</p>
|
|
118
|
+
<table v-else class="stats_table">
|
|
119
|
+
<thead>
|
|
120
|
+
<tr>
|
|
121
|
+
<th>{{ isFr ? "Page" : "Page" }}</th>
|
|
122
|
+
<th class="stats_num">{{ isFr ? "Pages vues" : "Page views" }}</th>
|
|
123
|
+
<th class="stats_num">{{ isFr ? "Utilisateurs" : "Users" }}</th>
|
|
124
|
+
</tr>
|
|
125
|
+
</thead>
|
|
126
|
+
<tbody>
|
|
127
|
+
<tr v-for="p in topPages" :key="p.path" :class="{ 'stats_row--current': p.path === data.path }">
|
|
128
|
+
<td>
|
|
129
|
+
<NuxtLink :to="p.path" class="stats_page">
|
|
130
|
+
<span class="stats_page_label">{{ labelOf(p.path) }}</span>
|
|
131
|
+
<span v-if="labelOf(p.path) !== p.path" class="stats_page_path">{{ p.path }}</span>
|
|
132
|
+
</NuxtLink>
|
|
133
|
+
</td>
|
|
134
|
+
<td class="stats_num">{{ rmNumber(p.pageviews[period]) }}</td>
|
|
135
|
+
<td class="stats_num">{{ rmNumber(p.users[period]) }}</td>
|
|
136
|
+
</tr>
|
|
137
|
+
</tbody>
|
|
138
|
+
</table>
|
|
139
|
+
</section>
|
|
140
|
+
|
|
141
|
+
<p class="stats_note">
|
|
142
|
+
{{ isFr ? "Source : PostHog, production" : "Source: PostHog, production" }} ({{ data.hosts.join(", ") }})
|
|
143
|
+
</p>
|
|
144
|
+
</template>
|
|
145
|
+
</div>
|
|
146
|
+
</template>
|
|
147
|
+
|
|
148
|
+
<script setup lang="ts">
|
|
149
|
+
import { rmNumber } from "@tekkare/romulus/utils/format";
|
|
150
|
+
import type { OipStats, OipStatsMetrics, OipStatsPeriod, OipStatsUser, OipStatsUsers } from "../../types/stats";
|
|
151
|
+
|
|
152
|
+
const props = defineProps<{
|
|
153
|
+
// Passees par OipShell a tous les panneaux ; les chiffres sont ceux de l'app.
|
|
154
|
+
appKey?: string;
|
|
155
|
+
superAdmin?: boolean;
|
|
156
|
+
/**
|
|
157
|
+
* Titre lisible d'un chemin (celui de la navigation), donne par l'app dans
|
|
158
|
+
* `panelProps.Statistics` : les lecteurs du panneau ne lisent pas les URL.
|
|
159
|
+
* Absent ou sans reponse, le chemin s'affiche tel quel.
|
|
160
|
+
*/
|
|
161
|
+
pageLabel?: (path: string) => string | undefined;
|
|
162
|
+
}>();
|
|
163
|
+
|
|
164
|
+
const labelOf = (path: string) => props.pageLabel?.(path) || path;
|
|
165
|
+
|
|
166
|
+
const { locale } = useI18n();
|
|
167
|
+
const route = useRoute();
|
|
168
|
+
const isFr = computed(() => locale.value !== "en");
|
|
169
|
+
|
|
170
|
+
const period = ref<OipStatsPeriod>("30d");
|
|
171
|
+
const internal = ref(false);
|
|
172
|
+
|
|
173
|
+
const periodOptions = computed(() => [
|
|
174
|
+
{ value: "today", label: isFr.value ? "Aujourd'hui" : "Today" },
|
|
175
|
+
{ value: "7d", label: isFr.value ? "7 jours" : "7 days" },
|
|
176
|
+
{ value: "30d", label: isFr.value ? "30 jours" : "30 days" },
|
|
177
|
+
{ value: "90d", label: isFr.value ? "90 jours" : "90 days" },
|
|
178
|
+
]);
|
|
179
|
+
|
|
180
|
+
// Une requete rend les quatre periodes : changer de periode ne refetch pas.
|
|
181
|
+
const panelConfig = useAppConfig().oip.panels.Statistics;
|
|
182
|
+
const { data, pending, error } = useFetch<OipStats>(panelConfig.endpoint, {
|
|
183
|
+
key: "oip-rail-statistics",
|
|
184
|
+
query: computed(() => ({ path: route.path, internal: internal.value ? "1" : "0" })),
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const errorMessage = computed(
|
|
188
|
+
() => (error.value as any)?.data?.message || error.value?.message || ""
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
function kpis(m: OipStatsMetrics) {
|
|
192
|
+
return [
|
|
193
|
+
{ label: isFr.value ? "Utilisateurs" : "Users", value: rmNumber(m.users) },
|
|
194
|
+
{ label: isFr.value ? "Visites" : "Visits", value: rmNumber(m.sessions) },
|
|
195
|
+
{ label: isFr.value ? "Pages vues" : "Page views", value: rmNumber(m.pageviews) },
|
|
196
|
+
];
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Perimetre de tout le panneau : l'ecran ouvert (par defaut) ou toute l'app.
|
|
200
|
+
const scope = ref<"app" | "page">("page");
|
|
201
|
+
|
|
202
|
+
// Classement des utilisateurs : dix par dix, remis a zero quand le
|
|
203
|
+
// perimetre change (periode, ecran, comptes Tekkare).
|
|
204
|
+
const USERS_PAGE = 10;
|
|
205
|
+
const scopeOptions = computed(() => [
|
|
206
|
+
{ value: "page", label: isFr.value ? "Cette page" : "This page" },
|
|
207
|
+
{ value: "app", label: isFr.value ? "Toute l'app" : "Whole app" },
|
|
208
|
+
]);
|
|
209
|
+
const users = ref<OipStatsUser[]>([]);
|
|
210
|
+
const usersHasMore = ref(false);
|
|
211
|
+
const usersLoading = ref(false);
|
|
212
|
+
const usersError = ref("");
|
|
213
|
+
let usersRequest = 0;
|
|
214
|
+
|
|
215
|
+
async function loadUsers(reset = false) {
|
|
216
|
+
const request = ++usersRequest;
|
|
217
|
+
if (reset) users.value = [];
|
|
218
|
+
usersLoading.value = true;
|
|
219
|
+
usersError.value = "";
|
|
220
|
+
try {
|
|
221
|
+
// Deduite de `endpoint` quand l'app ne la donne pas : une config d'app
|
|
222
|
+
// (ou un cache) d'avant cette cle enverrait sinon `$fetch(undefined)`.
|
|
223
|
+
const usersEndpoint = panelConfig.usersEndpoint ?? `${panelConfig.endpoint}/users`;
|
|
224
|
+
const res = await $fetch<OipStatsUsers>(usersEndpoint, {
|
|
225
|
+
query: {
|
|
226
|
+
period: period.value,
|
|
227
|
+
scope: scope.value,
|
|
228
|
+
path: route.path,
|
|
229
|
+
internal: internal.value ? "1" : "0",
|
|
230
|
+
offset: users.value.length,
|
|
231
|
+
limit: USERS_PAGE,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
if (request !== usersRequest) return;
|
|
235
|
+
// Une reponse sans liste (page HTML d'un serveur qui ne connait pas la
|
|
236
|
+
// route, par exemple) : on montre ce qui est arrive plutot que planter.
|
|
237
|
+
if (!Array.isArray(res?.users)) {
|
|
238
|
+
throw new Error(`${isFr.value ? "Réponse inattendue" : "Unexpected response"} : ${String(typeof res === "string" ? res : JSON.stringify(res)).slice(0, 160)}`);
|
|
239
|
+
}
|
|
240
|
+
users.value = [...users.value, ...res.users];
|
|
241
|
+
usersHasMore.value = res.hasMore;
|
|
242
|
+
} catch (err: any) {
|
|
243
|
+
if (request !== usersRequest) return;
|
|
244
|
+
usersError.value = err?.data?.message || err?.message || "";
|
|
245
|
+
} finally {
|
|
246
|
+
if (request === usersRequest) usersLoading.value = false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
watch([period, scope, internal, () => route.path], () => loadUsers(true), { immediate: true });
|
|
250
|
+
|
|
251
|
+
const screensHint = computed(() =>
|
|
252
|
+
isFr.value
|
|
253
|
+
? "Nombre de pages différentes ouvertes sur la période, langue et paramètres d'URL ignorés. 310 pages vues sur 22 écrans : 310 affichages répartis sur 22 pages. Chaque fiche (une marque, un produit) compte pour un écran."
|
|
254
|
+
: "Number of distinct pages opened in the period, ignoring language and URL parameters. 310 page views over 22 screens: 310 displays spread across 22 pages. Each detail page (a brand, a product) counts as one screen."
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
const seen = (iso: string) =>
|
|
258
|
+
iso ? new Date(iso).toLocaleDateString(isFr.value ? "fr-FR" : "en-GB", { day: "2-digit", month: "short" }) : "";
|
|
259
|
+
|
|
260
|
+
const topPages = computed(() =>
|
|
261
|
+
(data.value?.topPages ?? [])
|
|
262
|
+
.filter((p) => p.pageviews[period.value] > 0)
|
|
263
|
+
.sort((a, b) => b.pageviews[period.value] - a.pageviews[period.value])
|
|
264
|
+
.slice(0, 10)
|
|
265
|
+
);
|
|
266
|
+
</script>
|
|
267
|
+
|
|
268
|
+
<style scoped>
|
|
269
|
+
.stats_panel {
|
|
270
|
+
align-self: stretch;
|
|
271
|
+
display: flex;
|
|
272
|
+
flex-direction: column;
|
|
273
|
+
gap: 20px;
|
|
274
|
+
width: 100%;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.stats_controls {
|
|
278
|
+
display: flex;
|
|
279
|
+
flex-direction: column;
|
|
280
|
+
gap: 10px;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.stats_internal {
|
|
284
|
+
align-items: center;
|
|
285
|
+
color: var(--text-secondary);
|
|
286
|
+
cursor: pointer;
|
|
287
|
+
display: flex;
|
|
288
|
+
font-size: 13px;
|
|
289
|
+
gap: 8px;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.stats_loading {
|
|
293
|
+
display: flex;
|
|
294
|
+
flex-direction: column;
|
|
295
|
+
gap: 12px;
|
|
296
|
+
width: 100%;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.stats_section {
|
|
300
|
+
display: flex;
|
|
301
|
+
flex-direction: column;
|
|
302
|
+
gap: 10px;
|
|
303
|
+
width: 100%;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.stats_section_title {
|
|
307
|
+
align-items: center;
|
|
308
|
+
color: var(--text-secondary);
|
|
309
|
+
display: flex;
|
|
310
|
+
flex-wrap: wrap;
|
|
311
|
+
font-size: 12px;
|
|
312
|
+
font-weight: 600;
|
|
313
|
+
gap: 8px;
|
|
314
|
+
letter-spacing: 0.04em;
|
|
315
|
+
margin: 0;
|
|
316
|
+
text-transform: uppercase;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.stats_path {
|
|
320
|
+
font-weight: 400;
|
|
321
|
+
letter-spacing: 0;
|
|
322
|
+
text-transform: none;
|
|
323
|
+
word-break: break-all;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.stats_switches {
|
|
327
|
+
align-items: center;
|
|
328
|
+
display: flex;
|
|
329
|
+
flex-wrap: wrap;
|
|
330
|
+
gap: 10px;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.stats_periods {
|
|
334
|
+
flex: 1;
|
|
335
|
+
min-width: 280px;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.stats_rank {
|
|
339
|
+
color: var(--text-secondary);
|
|
340
|
+
width: 28px;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
.stats_user {
|
|
344
|
+
display: flex;
|
|
345
|
+
flex-direction: column;
|
|
346
|
+
min-width: 0;
|
|
347
|
+
overflow-wrap: anywhere;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
.stats_user_name {
|
|
351
|
+
color: var(--text-primary);
|
|
352
|
+
font-weight: 600;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
.stats_user_email {
|
|
356
|
+
color: var(--text-secondary);
|
|
357
|
+
font-size: 12px;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.stats_workspace {
|
|
361
|
+
overflow-wrap: anywhere;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.stats_hint {
|
|
365
|
+
cursor: help;
|
|
366
|
+
text-decoration: underline dotted;
|
|
367
|
+
text-underline-offset: 3px;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
.stats_more {
|
|
371
|
+
align-self: center;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.stats_kpis {
|
|
375
|
+
display: grid;
|
|
376
|
+
gap: 10px;
|
|
377
|
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
.stats_table {
|
|
381
|
+
border-collapse: collapse;
|
|
382
|
+
font-size: 13px;
|
|
383
|
+
width: 100%;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
.stats_table th {
|
|
387
|
+
border-bottom: 1px solid var(--border-default);
|
|
388
|
+
color: var(--text-secondary);
|
|
389
|
+
font-weight: 600;
|
|
390
|
+
padding: 6px 4px;
|
|
391
|
+
text-align: left;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.stats_table td {
|
|
395
|
+
border-bottom: 1px solid var(--border-default);
|
|
396
|
+
padding: 6px 4px;
|
|
397
|
+
vertical-align: middle;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
.stats_num {
|
|
401
|
+
text-align: right !important;
|
|
402
|
+
white-space: nowrap;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.stats_page {
|
|
406
|
+
color: var(--text-primary);
|
|
407
|
+
display: flex;
|
|
408
|
+
flex-direction: column;
|
|
409
|
+
overflow-wrap: anywhere;
|
|
410
|
+
text-decoration: none;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
.stats_page:hover .stats_page_label {
|
|
414
|
+
color: var(--color-primary);
|
|
415
|
+
text-decoration: underline;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
.stats_page_path {
|
|
419
|
+
color: var(--text-secondary);
|
|
420
|
+
font-size: 12px;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
.stats_path_label {
|
|
424
|
+
color: var(--text-primary);
|
|
425
|
+
letter-spacing: 0;
|
|
426
|
+
text-transform: none;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
.stats_row--current td {
|
|
430
|
+
background: var(--bg-surface-hover, var(--bg-surface));
|
|
431
|
+
font-weight: 600;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
.stats_empty,
|
|
435
|
+
.stats_note {
|
|
436
|
+
color: var(--text-secondary);
|
|
437
|
+
font-size: 12px;
|
|
438
|
+
font-style: italic;
|
|
439
|
+
margin: 0;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
.stats_error {
|
|
443
|
+
color: var(--color-danger, #d64545);
|
|
444
|
+
font-size: 13px;
|
|
445
|
+
margin: 0;
|
|
446
|
+
}
|
|
447
|
+
</style>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Reponse de GET /api/oip/stats, servie par le layer OIP (panneau « Statistiques »).
|
|
2
|
+
|
|
3
|
+
export const OIP_STATS_PERIODS = ["today", "7d", "30d", "90d"] as const;
|
|
4
|
+
export type OipStatsPeriod = (typeof OIP_STATS_PERIODS)[number];
|
|
5
|
+
|
|
6
|
+
export type OipStatsMetrics = {
|
|
7
|
+
/** Personnes distinctes (person_id PostHog). */
|
|
8
|
+
users: number;
|
|
9
|
+
/** Sessions distinctes ($session_id). */
|
|
10
|
+
sessions: number;
|
|
11
|
+
/** Vues de page ($pageview). */
|
|
12
|
+
pageviews: number;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type OipStatsTopPage = {
|
|
16
|
+
path: string;
|
|
17
|
+
pageviews: Record<OipStatsPeriod, number>;
|
|
18
|
+
users: Record<OipStatsPeriod, number>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type OipStats = {
|
|
22
|
+
/** Chemin de l'ecran mesure, normalise (sans langue ni barre finale). */
|
|
23
|
+
path: string;
|
|
24
|
+
/** Hotes PostHog retenus pour l'app (ceux de ses `prod_url`). */
|
|
25
|
+
hosts: string[];
|
|
26
|
+
page: Record<OipStatsPeriod, OipStatsMetrics>;
|
|
27
|
+
app: Record<OipStatsPeriod, OipStatsMetrics>;
|
|
28
|
+
topPages: OipStatsTopPage[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type OipStatsUser = {
|
|
32
|
+
email: string;
|
|
33
|
+
/** Prenom et nom PostHog, vide quand la fiche ne les porte pas. */
|
|
34
|
+
name: string;
|
|
35
|
+
/** Nom du dernier workspace vu, son id quand l'API ne le connait pas. */
|
|
36
|
+
workspace: string;
|
|
37
|
+
pageviews: number;
|
|
38
|
+
sessions: number;
|
|
39
|
+
/** Ecrans distincts vus. */
|
|
40
|
+
pages: number;
|
|
41
|
+
/** Derniere vue de page, ISO. */
|
|
42
|
+
lastSeen: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** Reponse de GET /api/oip/stats/users : une page du classement. */
|
|
46
|
+
export type OipStatsUsers = {
|
|
47
|
+
users: OipStatsUser[];
|
|
48
|
+
hasMore: boolean;
|
|
49
|
+
};
|
|
@@ -16,9 +16,10 @@ export type OipRailTileKey =
|
|
|
16
16
|
| "Topics"
|
|
17
17
|
| "Tutorials"
|
|
18
18
|
| "Accessibility"
|
|
19
|
-
| "Metadata"
|
|
19
|
+
| "Metadata"
|
|
20
|
+
| "Statistics";
|
|
20
21
|
|
|
21
|
-
export type OipRailGroupKey = "Documentation" | "ViewsAlerts";
|
|
22
|
+
export type OipRailGroupKey = "Documentation" | "ViewsAlerts" | "Admin";
|
|
22
23
|
|
|
23
24
|
export type OipRailEntry = {
|
|
24
25
|
key: OipRailTileKey;
|
|
@@ -32,11 +33,18 @@ export type OipRailEntry = {
|
|
|
32
33
|
frame?: boolean;
|
|
33
34
|
/** Sa pastille reste sur son onglet, sans s'ajouter a celle du groupe. */
|
|
34
35
|
ownCount?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Largeur propre du panneau, au lieu de `panelWidth`. Reservee aux panneaux
|
|
38
|
+
* d'outillage qui affichent des tableaux larges (statistiques).
|
|
39
|
+
*/
|
|
40
|
+
width?: string;
|
|
35
41
|
};
|
|
36
42
|
|
|
37
43
|
export const OIP_RAIL_GROUPS: Record<OipRailGroupKey, { label: { fr: string; en: string }; icon?: string }> = {
|
|
38
44
|
Documentation: { label: { fr: "À propos de l'app", en: "About the app" }, icon: "info" },
|
|
39
45
|
ViewsAlerts: { label: { fr: "Vues et alertes", en: "Views and alerts" } },
|
|
46
|
+
// Outillage superadmin : ses membres sont `admin`, le groupe n'existe que pour eux.
|
|
47
|
+
Admin: { label: { fr: "Admin", en: "Admin" }, icon: "shield-check" },
|
|
40
48
|
};
|
|
41
49
|
|
|
42
50
|
export const OIP_RAIL_TILES: OipRailEntry[] = [
|
|
@@ -65,7 +73,25 @@ export const OIP_RAIL_TILES: OipRailEntry[] = [
|
|
|
65
73
|
{ key: "Topics", label: { fr: "Mes sujets", en: "My topics" }, icon: "star" },
|
|
66
74
|
{ key: "Tutorials", label: { fr: "Tutoriels", en: "Tutorials" }, icon: "graduation-cap", frame: true },
|
|
67
75
|
{ key: "Accessibility", label: { fr: "Accessibilité", en: "Accessibility" }, icon: "person-arms-spread", frame: true },
|
|
68
|
-
|
|
76
|
+
// Statistiques d'abord : le groupe Admin s'ouvre sur elles. La largeur vaut
|
|
77
|
+
// pour tout le groupe, les onglets ne refluent pas le panneau.
|
|
78
|
+
{
|
|
79
|
+
key: "Statistics",
|
|
80
|
+
label: { fr: "Statistiques", en: "Statistics" },
|
|
81
|
+
icon: "chart-line-up",
|
|
82
|
+
group: "Admin",
|
|
83
|
+
admin: true,
|
|
84
|
+
frame: true,
|
|
85
|
+
width: "960px",
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
key: "Metadata",
|
|
89
|
+
label: { fr: "Métadonnées", en: "Metadata" },
|
|
90
|
+
icon: "brackets-curly",
|
|
91
|
+
group: "Admin",
|
|
92
|
+
admin: true,
|
|
93
|
+
frame: true,
|
|
94
|
+
},
|
|
69
95
|
];
|
|
70
96
|
|
|
71
97
|
/** Etat d'une tuile pour l'ecran : `true` la sert, un objet la sert avec sa pastille. */
|
package/layer/nuxt.config.ts
CHANGED
|
@@ -37,5 +37,14 @@ export default defineNuxtConfig({
|
|
|
37
37
|
oipEsIndicesToken: "",
|
|
38
38
|
/** Vrai en preproduction : interroge `staging_physical` quand il existe. */
|
|
39
39
|
oipEsIndicesStaging: false,
|
|
40
|
+
/**
|
|
41
|
+
* Panneau Statistiques : cle personnelle PostHog (scope query:read), id
|
|
42
|
+
* numerique du projet et hote de l'API (pas celui d'ingestion). Vides :
|
|
43
|
+
* POSTHOG_STATS_KEY (ou POSTHOG_PERSONAL_API_KEY), POSTHOG_PROJECT_ID,
|
|
44
|
+
* POSTHOG_API_HOST.
|
|
45
|
+
*/
|
|
46
|
+
oipPosthogKey: "",
|
|
47
|
+
oipPosthogProjectId: "",
|
|
48
|
+
oipPosthogHost: "",
|
|
40
49
|
},
|
|
41
50
|
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
import { OIP_STATS_PATH, OIP_STATS_SINCE, oipStatsContext } from "../../../utils/oipPosthog";
|
|
3
|
+
import { oipWorkspaceNames } from "../../../utils/oipWorkspaces";
|
|
4
|
+
import { OIP_STATS_PERIODS, type OipStatsPeriod, type OipStatsUsers } from "../../../../app/types/stats";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* GET /api/oip/stats/users?period=7d&scope=app&offset=0&limit=10&path=&internal=0,
|
|
8
|
+
* servi par le layer OIP (panneau « Statistiques »).
|
|
9
|
+
*
|
|
10
|
+
* Le classement des utilisateurs par vues de page sur la periode, pour toute
|
|
11
|
+
* l'app (`scope=app`) ou l'ecran `path` (`scope=page`), une page a la fois :
|
|
12
|
+
* `hasMore` dit s'il reste des lignes apres celle-ci.
|
|
13
|
+
*
|
|
14
|
+
* Le workspace est le dernier `workspace_id` porte par les vues de la
|
|
15
|
+
* personne sur la periode, nomme par la Virtual Lab API.
|
|
16
|
+
*
|
|
17
|
+
* Perimetre, cle et droit : cf. oipStatsContext.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const MAX_LIMIT = 100;
|
|
21
|
+
const MAX_OFFSET = 5000;
|
|
22
|
+
const CHUNK = 100;
|
|
23
|
+
|
|
24
|
+
const prop = (name: string) => `nullIf(toString(person.properties.${name}), '')`;
|
|
25
|
+
|
|
26
|
+
export default defineEventHandler(async (event): Promise<OipStatsUsers> => {
|
|
27
|
+
const { query, where, hogql } = await oipStatsContext(event);
|
|
28
|
+
|
|
29
|
+
const period = (OIP_STATS_PERIODS as readonly string[]).includes(String(query.period))
|
|
30
|
+
? (query.period as OipStatsPeriod)
|
|
31
|
+
: "7d";
|
|
32
|
+
const onPage = query.scope === "page";
|
|
33
|
+
const offset = Math.min(MAX_OFFSET, Math.max(0, Math.floor(Number(query.offset) || 0)));
|
|
34
|
+
const limit = Math.min(MAX_LIMIT, Math.max(1, Math.floor(Number(query.limit) || 10)));
|
|
35
|
+
|
|
36
|
+
// PostHog refuse OFFSET avec une cle personnelle : on lit depuis le haut du
|
|
37
|
+
// classement et on coupe ici. Une ligne de plus que demande : sa presence
|
|
38
|
+
// dit qu'il en reste. Lu par paquets de CHUNK lignes : les « Charger plus »
|
|
39
|
+
// suivants retombent sur la meme requete, servie par le cache.
|
|
40
|
+
const rowCap = Math.ceil((offset + limit + 1) / CHUNK) * CHUNK;
|
|
41
|
+
const ranking = hogql(`
|
|
42
|
+
SELECT
|
|
43
|
+
any(${prop("email")}),
|
|
44
|
+
any(${prop("firstName")}), any(${prop("lastName")}),
|
|
45
|
+
any(${prop("first_name")}), any(${prop("last_name")}), any(${prop("name")}),
|
|
46
|
+
argMaxIf(toString(properties.workspace_id), timestamp, notEmpty(coalesce(toString(properties.workspace_id), ''))),
|
|
47
|
+
count() AS pageviews,
|
|
48
|
+
uniq($session_id),
|
|
49
|
+
uniq(${OIP_STATS_PATH}),
|
|
50
|
+
max(timestamp) AS last_seen
|
|
51
|
+
FROM events
|
|
52
|
+
WHERE ${where} AND timestamp >= ${OIP_STATS_SINCE[period]}${onPage ? ` AND ${OIP_STATS_PATH} = {path}` : ""}
|
|
53
|
+
GROUP BY person_id
|
|
54
|
+
ORDER BY pageviews DESC, last_seen DESC
|
|
55
|
+
LIMIT ${rowCap}
|
|
56
|
+
`);
|
|
57
|
+
const [rows, workspaces] = await Promise.all([ranking, oipWorkspaceNames(event)]);
|
|
58
|
+
|
|
59
|
+
const text = (v: unknown) => (v == null ? "" : String(v).trim());
|
|
60
|
+
return {
|
|
61
|
+
hasMore: rows.length > offset + limit,
|
|
62
|
+
users: rows.slice(offset, offset + limit).map((r) => {
|
|
63
|
+
const [email, firstName, lastName, first_name, last_name, name, workspaceId, pageviews, sessions, pages, lastSeen] =
|
|
64
|
+
r;
|
|
65
|
+
const full =
|
|
66
|
+
[text(firstName), text(lastName)].filter(Boolean).join(" ") ||
|
|
67
|
+
[text(first_name), text(last_name)].filter(Boolean).join(" ") ||
|
|
68
|
+
text(name);
|
|
69
|
+
return {
|
|
70
|
+
email: text(email),
|
|
71
|
+
name: full,
|
|
72
|
+
workspace: workspaces.get(text(workspaceId)) ?? text(workspaceId),
|
|
73
|
+
pageviews: Number(pageviews) || 0,
|
|
74
|
+
sessions: Number(sessions) || 0,
|
|
75
|
+
pages: Number(pages) || 0,
|
|
76
|
+
lastSeen: text(lastSeen),
|
|
77
|
+
};
|
|
78
|
+
}),
|
|
79
|
+
};
|
|
80
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
import { OIP_STATS_PATH, OIP_STATS_SINCE, oipStatsContext } from "../../utils/oipPosthog";
|
|
3
|
+
import {
|
|
4
|
+
OIP_STATS_PERIODS,
|
|
5
|
+
type OipStats,
|
|
6
|
+
type OipStatsMetrics,
|
|
7
|
+
type OipStatsPeriod,
|
|
8
|
+
} from "../../../app/types/stats";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GET /api/oip/stats?path=/hubr/x&internal=0, servi par le layer OIP
|
|
12
|
+
* (panneau « Statistiques »).
|
|
13
|
+
*
|
|
14
|
+
* L'audience de l'ecran ouvert et de toute l'app, lue dans PostHog par une
|
|
15
|
+
* requete HogQL : personnes, sessions et vues de page, aujourd'hui et sur 7,
|
|
16
|
+
* 30 et 90 jours, plus les pages les plus vues.
|
|
17
|
+
*
|
|
18
|
+
* L'app est designee par l'hote de ses `prod_url` (`$host` des evenements),
|
|
19
|
+
* comme les notes la designent par son URL : le projet PostHog est commun a
|
|
20
|
+
* toute la flotte. Le plugin PostHog des apps ne capture ni localhost ni
|
|
21
|
+
* staging, il n'y a donc que la prod a compter.
|
|
22
|
+
*
|
|
23
|
+
* `internal=0` (defaut) ecarte les comptes @tekkare.com.
|
|
24
|
+
*
|
|
25
|
+
* Cle PostHog et perimetre commun : cf. oipStatsContext.
|
|
26
|
+
*
|
|
27
|
+
* Reservee aux superadmins, comme les metadonnees.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const TOP_PAGES = 100;
|
|
31
|
+
|
|
32
|
+
export default defineEventHandler(async (event): Promise<OipStats> => {
|
|
33
|
+
const { path, hosts, where, hogql } = await oipStatsContext(event);
|
|
34
|
+
const SINCE = OIP_STATS_SINCE;
|
|
35
|
+
const PATH = OIP_STATS_PATH;
|
|
36
|
+
|
|
37
|
+
const onPage = `${PATH} = {path}`;
|
|
38
|
+
const metrics = (scope: string, cond: string) =>
|
|
39
|
+
OIP_STATS_PERIODS.flatMap((p) => {
|
|
40
|
+
const c = `timestamp >= ${SINCE[p]}${cond ? ` AND ${cond}` : ""}`;
|
|
41
|
+
return [
|
|
42
|
+
`uniqIf(person_id, ${c}) AS ${scope}_users_${p.replace(/\W/g, "")}`,
|
|
43
|
+
`uniqIf($session_id, ${c}) AS ${scope}_sessions_${p.replace(/\W/g, "")}`,
|
|
44
|
+
`countIf(${c}) AS ${scope}_pageviews_${p.replace(/\W/g, "")}`,
|
|
45
|
+
];
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const [totals, top] = await Promise.all([
|
|
49
|
+
hogql(`SELECT ${[...metrics("page", onPage), ...metrics("app", "")].join(", ")} FROM events WHERE ${where}`),
|
|
50
|
+
hogql(`
|
|
51
|
+
SELECT ${PATH} AS page,
|
|
52
|
+
${OIP_STATS_PERIODS.map((p) => `countIf(timestamp >= ${SINCE[p]})`).join(", ")},
|
|
53
|
+
${OIP_STATS_PERIODS.map((p) => `uniqIf(person_id, timestamp >= ${SINCE[p]})`).join(", ")}
|
|
54
|
+
FROM events
|
|
55
|
+
WHERE ${where}
|
|
56
|
+
GROUP BY page
|
|
57
|
+
ORDER BY count() DESC
|
|
58
|
+
LIMIT ${TOP_PAGES}
|
|
59
|
+
`),
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
// Une ligne : page puis app, chaque periode portant users, sessions, pageviews.
|
|
63
|
+
const row = (totals[0] ?? []).map(Number);
|
|
64
|
+
const read = (offset: number) =>
|
|
65
|
+
Object.fromEntries(
|
|
66
|
+
OIP_STATS_PERIODS.map((p, i) => {
|
|
67
|
+
const [users = 0, sessions = 0, pageviews = 0] = row.slice(offset + i * 3, offset + i * 3 + 3);
|
|
68
|
+
return [p, { users, sessions, pageviews } satisfies OipStatsMetrics];
|
|
69
|
+
}),
|
|
70
|
+
) as Record<OipStatsPeriod, OipStatsMetrics>;
|
|
71
|
+
const span = OIP_STATS_PERIODS.length * 3;
|
|
72
|
+
|
|
73
|
+
const n = OIP_STATS_PERIODS.length;
|
|
74
|
+
const byPeriod = (cells: unknown[]) =>
|
|
75
|
+
Object.fromEntries(OIP_STATS_PERIODS.map((p, i) => [p, Number(cells[i]) || 0])) as Record<OipStatsPeriod, number>;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
path,
|
|
79
|
+
hosts,
|
|
80
|
+
page: read(0),
|
|
81
|
+
app: read(span),
|
|
82
|
+
topPages: top.map((r) => ({
|
|
83
|
+
path: String(r[0] || "/"),
|
|
84
|
+
pageviews: byPeriod(r.slice(1, 1 + n)),
|
|
85
|
+
users: byPeriod(r.slice(1 + n, 1 + 2 * n)),
|
|
86
|
+
})),
|
|
87
|
+
};
|
|
88
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { H3Event } from "h3";
|
|
2
|
+
import type { OipStatsPeriod } from "../../app/types/stats";
|
|
3
|
+
import { assertOipSuperAdmin, oipAppUrls } from "./oipGuards";
|
|
4
|
+
|
|
5
|
+
// Socle des routes /api/oip/stats* (panneau « Statistiques ») : droit, cle
|
|
6
|
+
// PostHog, perimetre de l'app et appel HogQL. Prefixe `oip` pour ne pas
|
|
7
|
+
// masquer les helpers des apps (utils serveur auto-importes).
|
|
8
|
+
|
|
9
|
+
// Debut de chaque periode, dans le fuseau du projet PostHog.
|
|
10
|
+
export const OIP_STATS_SINCE: Record<OipStatsPeriod, string> = {
|
|
11
|
+
today: "toStartOfDay(now())",
|
|
12
|
+
"7d": "now() - INTERVAL 7 DAY",
|
|
13
|
+
"30d": "now() - INTERVAL 30 DAY",
|
|
14
|
+
"90d": "now() - INTERVAL 90 DAY",
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Chemin sans prefixe de langue ni barre finale : `/fr/hubr/x/` et `/hubr/x`
|
|
18
|
+
// sont le meme ecran (meme peigne que `oipStatsNormalize`).
|
|
19
|
+
const BARE = "replaceRegexpOne(replaceRegexpOne(toString(properties.$pathname), '^/(en|fr)(/|$)', '/'), '/+$', '')";
|
|
20
|
+
export const OIP_STATS_PATH = `if(empty(${BARE}), '/', ${BARE})`;
|
|
21
|
+
|
|
22
|
+
// PostHog plafonne son API de requetes (429 au-dela) : chaque ouverture du
|
|
23
|
+
// panneau, chaque periode, chaque « Charger plus » en coutait une. Les memes
|
|
24
|
+
// requetes sont donc servies de memoire cinq minutes ; les chiffres d'une
|
|
25
|
+
// audience n'ont pas besoin d'etre plus frais.
|
|
26
|
+
const CACHE_TTL_MS = 5 * 60 * 1000;
|
|
27
|
+
const CACHE_MAX = 500;
|
|
28
|
+
const RETRY_MAX_S = 20;
|
|
29
|
+
const cache = new Map<string, { at: number; rows: Promise<unknown[][]> }>();
|
|
30
|
+
|
|
31
|
+
export function oipStatsNormalize(path: string) {
|
|
32
|
+
return (path.split("?")[0] || "").replace(/^\/(fr|en)(?=\/|$)/, "").replace(/\/+$/, "") || "/";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Verifie le droit et la configuration, puis rend le perimetre commun : les
|
|
37
|
+
* vues de page des 90 derniers jours sur les hotes de l'app (`prod_url`),
|
|
38
|
+
* sans les comptes @tekkare.com sauf `internal=1`, et `hogql` pour
|
|
39
|
+
* interroger PostHog. `path` (normalise) est passe en placeholder `{path}`.
|
|
40
|
+
*
|
|
41
|
+
* Cle personnelle PostHog (scope query:read) et id numerique du projet :
|
|
42
|
+
* `runtimeConfig.oipPosthogKey` / `oipPosthogProjectId`, sinon les env
|
|
43
|
+
* POSTHOG_STATS_KEY (ou POSTHOG_PERSONAL_API_KEY, le nom d'oip7) /
|
|
44
|
+
* POSTHOG_PROJECT_ID. La cle ne quitte jamais le serveur.
|
|
45
|
+
*/
|
|
46
|
+
export async function oipStatsContext(event: H3Event) {
|
|
47
|
+
await assertOipSuperAdmin(event);
|
|
48
|
+
|
|
49
|
+
const config = useRuntimeConfig();
|
|
50
|
+
const key =
|
|
51
|
+
(config.oipPosthogKey as string) || process.env.POSTHOG_STATS_KEY || process.env.POSTHOG_PERSONAL_API_KEY;
|
|
52
|
+
const projectId = (config.oipPosthogProjectId as string) || process.env.POSTHOG_PROJECT_ID;
|
|
53
|
+
if (!key || !projectId) {
|
|
54
|
+
throw createError({
|
|
55
|
+
status: 501,
|
|
56
|
+
message: "PostHog stats are not configured (POSTHOG_STATS_KEY, POSTHOG_PROJECT_ID)",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
const apiHost = ((config.oipPosthogHost as string) || process.env.POSTHOG_API_HOST || "https://eu.posthog.com").replace(
|
|
60
|
+
/\/+$/,
|
|
61
|
+
"",
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const query = getQuery(event);
|
|
65
|
+
const path = oipStatsNormalize(String(query.path || "/"));
|
|
66
|
+
const internal = query.internal === "1";
|
|
67
|
+
const hosts = [...new Set(oipAppUrls().map((url) => new URL(url).host))];
|
|
68
|
+
|
|
69
|
+
// Valeurs passees en placeholders HogQL : PostHog les substitue lui-meme.
|
|
70
|
+
const values: Record<string, string> = { path };
|
|
71
|
+
hosts.forEach((h, i) => (values[`host${i}`] = h));
|
|
72
|
+
|
|
73
|
+
const where = [
|
|
74
|
+
"event = '$pageview'",
|
|
75
|
+
`timestamp >= ${OIP_STATS_SINCE["90d"]}`,
|
|
76
|
+
`toString(properties.$host) IN (${hosts.map((_, i) => `{host${i}}`).join(", ")})`,
|
|
77
|
+
internal ? "" : "NOT endsWith(lower(coalesce(toString(person.properties.email), '')), '@tekkare.com')",
|
|
78
|
+
]
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
.join(" AND ");
|
|
81
|
+
|
|
82
|
+
function hogql(sql: string): Promise<unknown[][]> {
|
|
83
|
+
// Seules les valeurs que la requete lit : le classement de toute l'app ne
|
|
84
|
+
// depend pas de l'ecran ouvert, et doit rester en cache d'une page a l'autre.
|
|
85
|
+
const used = Object.fromEntries(Object.entries(values).filter(([name]) => sql.includes(`{${name}}`)));
|
|
86
|
+
const cacheKey = JSON.stringify([projectId, sql, used]);
|
|
87
|
+
const hit = cache.get(cacheKey);
|
|
88
|
+
if (hit && Date.now() - hit.at < CACHE_TTL_MS) return hit.rows;
|
|
89
|
+
|
|
90
|
+
if (cache.size >= CACHE_MAX) {
|
|
91
|
+
for (const [k, v] of cache) if (Date.now() - v.at >= CACHE_TTL_MS) cache.delete(k);
|
|
92
|
+
if (cache.size >= CACHE_MAX) cache.delete(cache.keys().next().value!);
|
|
93
|
+
}
|
|
94
|
+
// La promesse elle-meme : deux appels simultanes partagent la requete.
|
|
95
|
+
const rows = run(sql, used);
|
|
96
|
+
cache.set(cacheKey, { at: Date.now(), rows });
|
|
97
|
+
rows.catch(() => cache.delete(cacheKey));
|
|
98
|
+
return rows;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function run(sql: string, used: Record<string, string>, retried = false): Promise<unknown[][]> {
|
|
102
|
+
try {
|
|
103
|
+
const res = await $fetch<{ results?: unknown[][] }>(`${apiHost}/api/projects/${projectId}/query/`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
106
|
+
body: { query: { kind: "HogQLQuery", query: sql, values: used } },
|
|
107
|
+
});
|
|
108
|
+
return res.results || [];
|
|
109
|
+
} catch (err: any) {
|
|
110
|
+
const status = err?.response?.status ?? err?.statusCode ?? "?";
|
|
111
|
+
const detail = err?.data?.detail || err?.data?.message || err?.message || String(err);
|
|
112
|
+
console.error(`[oip/stats] PostHog query failed (${status}): ${String(detail).slice(0, 300)}`);
|
|
113
|
+
if (status === 429) {
|
|
114
|
+
// « Request was throttled. Expected available in 1028 seconds. »
|
|
115
|
+
const wait = Number(/available in (\d+) second/.exec(String(detail))?.[1]);
|
|
116
|
+
// Limite a la minute (l'ouverture du panneau envoie trois requetes
|
|
117
|
+
// d'un coup) : on attend et on reessaie une fois. Le quota horaire,
|
|
118
|
+
// lui, se dit au lecteur.
|
|
119
|
+
if (!retried && wait && wait <= RETRY_MAX_S) {
|
|
120
|
+
await new Promise((resolve) => setTimeout(resolve, (wait + 1) * 1000));
|
|
121
|
+
return run(sql, used, true);
|
|
122
|
+
}
|
|
123
|
+
const minutes = wait ? Math.max(1, Math.ceil(wait / 60)) : 1;
|
|
124
|
+
throw createError({
|
|
125
|
+
status: 429,
|
|
126
|
+
message: `Quota de requêtes PostHog atteint, réessayer dans ${minutes} min`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
throw createError({ status: 502, message: `PostHog query failed (${status})` });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return { query, path, hosts, where, hogql };
|
|
134
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { H3Event } from "h3";
|
|
2
|
+
import { getOipAccessToken } from "./oipAuth";
|
|
3
|
+
|
|
4
|
+
// Noms des workspaces de la Virtual Lab API, par id, pour le panneau
|
|
5
|
+
// « Statistiques » : PostHog ne porte que `workspace_id` sur les evenements.
|
|
6
|
+
// Prefixe `oip` pour ne pas masquer les helpers des apps.
|
|
7
|
+
|
|
8
|
+
// La liste pagine a 100 et trie par date de creation : une seule page
|
|
9
|
+
// cacherait les plus anciens.
|
|
10
|
+
const PAGE_SIZE = 100;
|
|
11
|
+
const MAX_PAGES = 20;
|
|
12
|
+
const TTL_MS = 10 * 60 * 1000;
|
|
13
|
+
|
|
14
|
+
let cache: { at: number; names: Map<string, string> } | null = null;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Id vers nom de tous les workspaces, lus par `/superadmin/workspaces` avec le
|
|
18
|
+
* jeton de l'appelant (deja verifie superadmin), gardes dix minutes. En cas
|
|
19
|
+
* d'echec, une table vide : les statistiques restent lisibles sans les noms.
|
|
20
|
+
*/
|
|
21
|
+
export async function oipWorkspaceNames(event: H3Event): Promise<Map<string, string>> {
|
|
22
|
+
if (cache && Date.now() - cache.at < TTL_MS) return cache.names;
|
|
23
|
+
|
|
24
|
+
const token = await getOipAccessToken(event);
|
|
25
|
+
const apiUrl = (useRuntimeConfig().oipVirtualLabApiUrl as string) || process.env.VIRTUAL_LAB_API_URL;
|
|
26
|
+
if (!token || !apiUrl) return new Map();
|
|
27
|
+
|
|
28
|
+
type Page = { workspaces?: { id?: string; name?: string }[]; pagination?: { totalPages?: number } };
|
|
29
|
+
const listPage = (page: number) =>
|
|
30
|
+
$fetch<Page>(`${apiUrl}/superadmin/workspaces`, {
|
|
31
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
32
|
+
query: { page, limit: PAGE_SIZE },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const first = await listPage(1);
|
|
37
|
+
const pages = [first];
|
|
38
|
+
const total = Math.min(first.pagination?.totalPages ?? 1, MAX_PAGES);
|
|
39
|
+
if (total > 1) pages.push(...(await Promise.all(Array.from({ length: total - 1 }, (_, i) => listPage(i + 2)))));
|
|
40
|
+
|
|
41
|
+
const names = new Map<string, string>();
|
|
42
|
+
for (const page of pages) {
|
|
43
|
+
for (const w of page.workspaces ?? []) if (w.id && w.name) names.set(w.id, w.name);
|
|
44
|
+
}
|
|
45
|
+
cache = { at: Date.now(), names };
|
|
46
|
+
return names;
|
|
47
|
+
} catch (err: any) {
|
|
48
|
+
console.error(`[oip/stats] workspace list failed (${err?.response?.status ?? err?.statusCode ?? "?"})`);
|
|
49
|
+
return new Map();
|
|
50
|
+
}
|
|
51
|
+
}
|