data-primals-engine 1.4.3 → 1.5.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 (50) hide show
  1. package/README.md +878 -867
  2. package/client/package-lock.json +49 -0
  3. package/client/package.json +1 -0
  4. package/client/src/App.jsx +1 -1
  5. package/client/src/App.scss +13 -3
  6. package/client/src/AssistantChat.scss +3 -2
  7. package/client/src/DashboardView.jsx +569 -569
  8. package/client/src/DataEditor.jsx +2 -2
  9. package/client/src/DataLayout.jsx +1 -8
  10. package/client/src/DataTable.jsx +26 -4
  11. package/client/src/Field.jsx +1825 -1788
  12. package/client/src/FlexDataRenderer.jsx +2 -0
  13. package/client/src/FlexTreeUtils.js +1 -1
  14. package/client/src/KPIDialog.jsx +11 -1
  15. package/client/src/ModelCreator.jsx +1 -2
  16. package/client/src/ModelCreatorField.jsx +23 -27
  17. package/client/src/ModelList.jsx +1 -1
  18. package/client/src/constants.js +1 -1
  19. package/client/src/hooks/useTutorials.jsx +62 -65
  20. package/client/src/translations.js +2 -0
  21. package/package.json +2 -1
  22. package/perf/README.md +147 -0
  23. package/perf/artillery-hooks.js +37 -0
  24. package/perf/perf-shot-hardwork.yml +84 -0
  25. package/perf/perf-shot-search.yml +45 -0
  26. package/perf/setup.yml +26 -0
  27. package/server.js +1 -1
  28. package/src/constants.js +2 -27
  29. package/src/core.js +15 -1
  30. package/src/data.js +1 -1
  31. package/src/defaultModels.js +1544 -1540
  32. package/src/email.js +5 -2
  33. package/src/engine.js +5 -3
  34. package/src/filter.js +5 -3
  35. package/src/modules/assistant/assistant.js +3 -1
  36. package/src/modules/bucket.js +12 -15
  37. package/src/modules/data/data.backup.js +11 -8
  38. package/src/modules/data/data.js +6 -3
  39. package/src/modules/data/data.operations.js +3231 -2999
  40. package/src/modules/data/data.routes.js +1821 -1785
  41. package/src/modules/data/data.scheduling.js +2 -1
  42. package/src/modules/data/data.validation.js +4 -1
  43. package/src/modules/file.js +4 -2
  44. package/src/modules/user.js +4 -1
  45. package/src/modules/workflow.js +9 -10
  46. package/src/openai.jobs.js +2 -0
  47. package/src/packs.js +22 -5
  48. package/src/providers.js +22 -7
  49. package/test/data.integration.test.js +1060 -981
  50. package/test/import_export.integration.test.js +1 -1
@@ -33,8 +33,10 @@ const formatDate = (value, type, t) => {
33
33
 
34
34
  const FlexDataRenderer = ({ value, fieldDefinition,data }) => {
35
35
  const { t, i18n } = useTranslation();
36
+ const lang = (i18n.resolvedLanguage || i18n.language).split(/[-_]/)?.[0];
36
37
  const {models} = useModelContext()
37
38
  const {me} = useAuthContext()
39
+
38
40
  // 'translatedLabel' is added by getFieldDefinitionByPath
39
41
  const field = fieldDefinition;
40
42
  const translatedLabel = t('model_'+field.name, field.name);
@@ -36,7 +36,7 @@ export const createNewNode = (type) => {
36
36
  return {
37
37
  ...baseNode,
38
38
  type: 'item',
39
- itemStyle: { width: '100px', height: '50px' },
39
+ itemStyle: { },
40
40
  content: { type: 'placeholder' }
41
41
  };
42
42
  }
@@ -1,11 +1,16 @@
1
1
  import React, { useState } from 'react';
2
2
  import { Dialog } from './Dialog.jsx'; // Assure-toi que Dialog est importé
3
3
  import { useTranslation } from 'react-i18next';
4
+ import {FaAd, FaPlus} from "react-icons/fa";
5
+ import {getUserHash} from "../../src/data.js";
6
+ import {useNavigate} from "react-router-dom";
7
+ import {useAuthContext} from "./contexts/AuthContext.jsx";
4
8
 
5
9
  function KPIDialog({ availableKpis = [], onAddKpi, onClose }) {
6
10
  const { t } = useTranslation();
7
11
  const [searchTerm, setSearchTerm] = useState('');
8
-
12
+ const nav = useNavigate()
13
+ const {me} = useAuthContext()
9
14
  const filteredKpis = (availableKpis || []).filter(kpi =>
10
15
  kpi.name.value.toLowerCase().includes(searchTerm.toLowerCase())
11
16
  );
@@ -32,6 +37,11 @@ function KPIDialog({ availableKpis = [], onAddKpi, onClose }) {
32
37
  <li>{t('dashboards.no_kpi_found', 'Aucun KPI disponible ou correspondant.')}</li>
33
38
  )}
34
39
  </ul>
40
+ <div className={"flex mg-2"}>
41
+ <button onClick={() => {
42
+ nav(`/user/${getUserHash(me)}/?model=kpi`);
43
+ }} className={"btn"}><FaPlus />{t('dashboards.addKpi', 'Nouvel indicateur KPI')}</button>
44
+ </div>
35
45
  </Dialog>
36
46
  );
37
47
  }
@@ -660,11 +660,10 @@ const ModelCreator = forwardRef(({ initialPrompt = '', onModelGenerated, autoGen
660
660
  {/* Boutons d'action, visibles si un modèle est affiché ou en mode manuel */}
661
661
  {(showModel || !useAI || initialModel) && (
662
662
  <div className="actions flex">
663
- {initialModel && (
663
+
664
664
  <Button type="button" onClick={handleAddField}>
665
665
  <FaPlus /> <Trans i18nKey={"btns.addField"}>Ajouter un champ</Trans>
666
666
  </Button>
667
- )}
668
667
 
669
668
  <Button type="submit">
670
669
  <FaSave />
@@ -72,7 +72,6 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
72
72
  newFields[index].name = e.target.value;
73
73
  setFields(newFields);
74
74
  }}
75
- help={t('modelcreator.name.hint')}
76
75
  required
77
76
  after={!(!modelLocked && isLocalUser(me) && field.locked) && !field._isNewField && (
78
77
  <Button type={"button"} className={"btn-form btn-last"}
@@ -212,10 +211,10 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
212
211
 
213
212
  <div className="flex flex-no-wrap">
214
213
  {hint('modelcreator.relationFilter.hint')}
215
- <label className="checkbox-label flex flex-1"><Trans
216
- i18nKey={"modelcreator.relationFilter"}>Filtre</Trans> :
217
- <input
218
- type="checkbox"
214
+ <div className="checkbox-label flex flex-1">
215
+ <CheckboxField
216
+ label={<Trans
217
+ i18nKey={"modelcreator.relationFilter"}>Filtre</Trans>}
219
218
  disabled={modelLocked || (isLocalUser(me) && field.locked)}
220
219
  checked={field.relationFilter !== undefined}
221
220
  onChange={(e) => {
@@ -228,7 +227,7 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
228
227
  setFields(newFields);
229
228
  }}
230
229
  />
231
- </label>
230
+ </div>
232
231
  </div>)}
233
232
 
234
233
 
@@ -286,7 +285,6 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
286
285
  newFields[index].multiline = e;
287
286
  setFields(newFields);
288
287
  }}
289
- help={field.multiline && t('modelcreator.multiline.hint')}
290
288
  />
291
289
  </div>
292
290
  </div>
@@ -295,28 +293,26 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
295
293
 
296
294
  {['string', 'string_t', 'richtext', 'richtext_t', 'email', 'phone', 'url', 'password', 'code'].includes(field.itemsType || field.type) && (
297
295
  <>
298
- <div className={"flex flex-no-wrap"}>
296
+ <div className={"flex flex-no-wrap field-bg"}>
299
297
  {hint('modelcreator.maxlength.hint')}
300
- <label className={"flex flex-1"}>
301
- <Trans i18nKey={"modelcreator.maxlength"}>Longueur
302
- maximale :</Trans>
303
- <NumberField
298
+ <div className={"flex-1"}><NumberField
304
299
  disabled={modelLocked || (isLocalUser(me) && field.locked)}
305
300
  step={1}
306
301
  min={0}
302
+ label={<Trans i18nKey={"modelcreator.maxlength"}>Longueur
303
+ maximale :</Trans>}
307
304
  className={"flex-1"}
308
305
  value={field.maxlength}
309
306
  onChange={(e) => {
310
307
  const newFields = [...fields];
311
- const val = parseInt(e, 10);
312
- if (!val)
308
+ const val = parseInt(e.target.value, 10);
309
+ if (isNaN(val))
313
310
  newFields[index].maxlength = undefined;
314
311
  else
315
312
  newFields[index].maxlength = val;
316
313
  setFields(newFields);
317
314
  }}
318
- />
319
- </label>
315
+ /></div>
320
316
  </div>
321
317
  </>
322
318
  )}
@@ -438,7 +434,6 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
438
434
  newFields[index].required = e;
439
435
  setFields(newFields);
440
436
  }}
441
- help={field.required && t('modelcreator.required.hint')}
442
437
  />
443
438
  </div>
444
439
  </div>
@@ -456,7 +451,6 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
456
451
  newFields[index].unique = e;
457
452
  setFields(newFields);
458
453
  }}
459
- help={field.unique && t('modelcreator.unique.hint')}
460
454
  />
461
455
  </div>
462
456
  </div>
@@ -555,7 +549,6 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
555
549
  disabled={modelLocked || (isLocalUser(me) && field.locked)}
556
550
  onChange={(e) => {
557
551
  const newFields = [...fields];
558
-
559
552
  newFields[index].default = e.value;
560
553
  setFields(newFields);
561
554
  }}
@@ -626,10 +619,12 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
626
619
 
627
620
  {(['datetime', 'date'].includes(field.itemsType || field.type)) && (
628
621
  <>
629
- <div className="flex flex-no-wrap mg-item">
622
+ <div
623
+ className="flex flex-no-wrap mg-item">
630
624
  {hint('modelcreator.min.hint')}
631
- <label className={"flex-1"}><Trans i18nKey={"modelcreator.min"}>Valeur minimale :</Trans></label>
632
- <div className="flex flex-1 field-bg "><input
625
+ <div className="flex field-bg flex-1">
626
+ <label className={"flex-1"}><Trans i18nKey={"modelcreator.min"}>Valeur minimale :</Trans></label>
627
+ <input
633
628
  className={"flex-1"}
634
629
  disabled={modelLocked || (isLocalUser(me) && field.locked)}
635
630
  type={field.type === 'datetime' ? 'datetime-local' : field.type}
@@ -643,13 +638,16 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
643
638
  }
644
639
  setFields(newFields);
645
640
  }}
646
- /></div>
641
+ />
642
+ </div>
647
643
  </div>
648
644
 
649
645
  <div className="flex flex-no-wrap mg-item">
650
646
  {hint('modelcreator.max.hint')}
651
- <label className={"flex-1"}><Trans i18nKey={"modelcreator.max"}>Valeur maximale :</Trans></label>
652
- <div className="flex flex-1 field-bg "><input
647
+ <div className="flex field-bg flex-1">
648
+ <label className={"flex-1"}><Trans i18nKey={"modelcreator.max"}>Valeur maximale :</Trans></label>
649
+ <input
650
+ className={"flex-1"}
653
651
  disabled={modelLocked || (isLocalUser(me) && field.locked)}
654
652
  type={field.type === 'datetime' ? 'datetime-local' : field.type}
655
653
  value={field.type === "number" ? (field.max + '').replace('.', ',') : field.max}
@@ -807,7 +805,6 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
807
805
  newFields[index].asMain = e;
808
806
  setFields(newFields);
809
807
  }}
810
- help={field.asMain && t('modelcreator.asMain.hint')}
811
808
  />
812
809
  </div>
813
810
  </div>)}
@@ -858,7 +855,6 @@ const ModelCreatorField = ({model, handleRenameField, handleRemoveField, handleU
858
855
  newFields[index].hiddenable = e;
859
856
  setFields(newFields);
860
857
  }}
861
- help={field.unique && t('modelcreator.hiddenable.hint')}
862
858
  />
863
859
  </div>
864
860
  </div>
@@ -190,7 +190,7 @@ export function ModelList({ onModelSelect, onCreateModel, onImportModel, onEditM
190
190
  key={'modelist' + model.name} onClick={() => generatedModels.some(g => g.name === model.name) ? onEditModel(model) : onModelSelect(model)}>
191
191
  <div className="flex flex-center flex-fw">
192
192
  <div
193
- className="flex flex-1 flex-no-wrap break-word">
193
+ className="flex flex-1 flex-no-wrap break-word gap-2">
194
194
  <div className={"icon"}>{IconComponent ? IconComponent : <></>}</div>
195
195
  <div>{t(`model_${model.name}`, model.name)} {generatedModels.some(f => f.name === model.name) ? '(tmp)' : (mods.some(f => f.mod === model.name) ? `(${mods.find(f => f.mod === model.name).count})` : '')}</div></div>
196
196
  <div className="btns">
@@ -57,7 +57,7 @@ export const profiles = {
57
57
  }, // budget,
58
58
  'developer': {
59
59
  "packs": ["Multilingual starter pack", "Website Starter Pack"],
60
- "models": ['alert','endpoint','request','webpage', 'content', 'taxonomy', 'resource', 'translation', 'contact', 'location', 'channel', 'lang', 'token', 'message', 'ticket', 'user', 'permission', 'role']
60
+ "models": ['alert','endpoint','request','kpi','webpage', 'content', 'taxonomy', 'resource', 'translation', 'contact', 'location', 'channel', 'lang', 'token', 'message', 'ticket', 'user', 'permission', 'role']
61
61
  },
62
62
  'company': {
63
63
  "packs": ["Multilingual starter pack", "E-commerce Starter Kit"],
@@ -4,7 +4,7 @@ import { useUI } from '../contexts/UIContext.jsx';
4
4
  import { useNotificationContext } from '../NotificationProvider.jsx';
5
5
  import { tutorialsConfig } from '../tutorials.js';
6
6
  import { useTranslation } from 'react-i18next';
7
- import { useCallback, useEffect, useMemo, useRef } from 'react';
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
8
  import useLocalStorage from "./useLocalStorage.js";
9
9
  import {Event} from "../../../src/events.js";
10
10
 
@@ -20,9 +20,9 @@ export const useTutorials = () => {
20
20
  const { allTourSteps, setCurrentTourSteps, setIsTourOpen } = useUI();
21
21
  const { addNotification } = useNotificationContext();
22
22
 
23
- // Refs pour gérer la file d'attente des vérifications
24
- const isCheckingRef = useRef(false);
25
- const isCheckQueuedRef = useRef(false);
23
+ // Ref pour "verrouiller" la vérification d'une étape spécifique et éviter les notifications en double
24
+ // à cause des re-renders rapides (race condition).
25
+ const completingStepRef = useRef(null);
26
26
 
27
27
  const { data: tutorials, isLoading: isLoadingTutorials } = useQuery('tutorials', async () => {
28
28
  return tutorialsConfig.map(t => ({ ...t, _id: t.id }));
@@ -119,102 +119,99 @@ export const useTutorials = () => {
119
119
  updateActiveTutorial(newActiveState);
120
120
  launchTour(firstStage.tourName);
121
121
  };
122
-
122
+
123
123
  const triggerTutorialCheck = useCallback(async () => {
124
- if (isCheckingRef.current) {
125
- console.log('[Tutoriels] Vérification déjà en cours, mise en file d\'attente.');
126
- isCheckQueuedRef.current = true; // Mettre en attente
124
+ // S'il n'y a pas de tutoriel actif, on ne fait rien.
125
+ if (!me?.activeTutorial || !tutorials) {
126
+ return;
127
+ }
128
+
129
+ const { id: currentTutorialId, stage: currentStageNumber } = me.activeTutorial;
130
+ const stepIdentifier = `${currentTutorialId}-${currentStageNumber}`;
131
+
132
+ // --- VERROUILLAGE (LOCK) ---
133
+ // Si on est déjà en train de traiter cette étape exacte, on ignore les appels suivants
134
+ // pour éviter les notifications multiples.
135
+ if (completingStepRef.current === stepIdentifier) {
136
+ console.log(`[Tutoriels] Vérification pour l'étape ${stepIdentifier} déjà en cours. On ignore.`);
127
137
  return;
128
138
  }
129
- if (!me?.activeTutorial || !tutorials) return;
130
139
 
131
- isCheckingRef.current = true;
132
- console.log('[Tutoriels] Démarrage de la vérification en chaîne...');
140
+ const tutorial = tutorials.find(t => t._id === currentTutorialId);
141
+ const stageConfig = tutorial?.stages.find(s => s.stage === currentStageNumber);
142
+
143
+ if (!stageConfig) return; // Pas d'étape active à vérifier
133
144
 
134
145
  try {
135
- let currentActiveState = me.activeTutorial;
146
+ // On pose le verrou pour cette étape
147
+ completingStepRef.current = stepIdentifier;
148
+ console.log(`[Tutoriels] Verrouillage et vérification de l'étape ${stepIdentifier}`);
136
149
 
137
- while (true) {
138
- const tutorial = tutorials.find(t => t._id === currentActiveState.id);
139
- if (!tutorial) break;
150
+ const { isCompleted } = await checkCompletionCondition(stageConfig.completionCondition);
140
151
 
141
- const stageConfig = tutorial.stages.find(s => s.stage === currentActiveState.stage);
142
- if (!stageConfig) {
143
- console.log('[Tutoriels] Fin du tutoriel, attribution des récompenses.');
144
- claimRewards(tutorial._id);
145
- updateActiveTutorial(null);
146
- setIsTourOpen(false);
147
- break;
148
- }
152
+ if (isCompleted) {
153
+ console.log(`[Tutoriels] Étape ${currentStageNumber} complétée.`);
149
154
 
150
- const { isCompleted } = await checkCompletionCondition(stageConfig.completionCondition);
155
+ const titleKey = `tutorial.${tutorial.id}.stage.${currentStageNumber}.name`;
156
+ const descriptionKey = `tutorial.${tutorial.id}.stage.${currentStageNumber}.description`;
151
157
 
152
- if (isCompleted) {
153
- console.log(`[Tutoriels] Étape ${currentActiveState.stage} complétée, passage à la suivante.`);
158
+ addNotification({
159
+ title: t(titleKey, stageConfig.name),
160
+ message: t(descriptionKey, stageConfig.description),
161
+ status: 'success',
162
+ id: `tuto-step-${stepIdentifier}` // ID unique pour la notification
163
+ });
154
164
 
155
- // Clés de traduction structurées et robustes
156
- const titleKey = `tutorial.${tutorial.id}.stage.${currentActiveState.stage}.name`;
157
- const descriptionKey = `tutorial.${tutorial.id}.stage.${currentActiveState.stage}.description`;
165
+ // On passe à l'étape suivante (ou on termine le tutoriel)
166
+ const nextStageNumber = currentStageNumber + 1;
167
+ const nextStage = tutorial.stages.find(s => s.stage === nextStageNumber);
158
168
 
159
- addNotification({
160
- title: t(titleKey, stageConfig.name),
161
- message: t(descriptionKey, stageConfig.description),
162
- status: 'success'
163
- });
164
- currentActiveState = { id: currentActiveState.id, stage: currentActiveState.stage + 1 };
169
+ if (nextStage) {
170
+ updateActiveTutorial({ id: currentTutorialId, stage: nextStageNumber });
171
+ launchTour(nextStage.tourName);
165
172
  } else {
166
- console.log(`[Tutoriels] Arrêt à l'étape ${currentActiveState.stage} (non complétée).`);
167
- if (currentActiveState.stage !== me.activeTutorial.stage) {
168
- updateActiveTutorial(currentActiveState);
169
- launchTour(stageConfig.tourName);
170
- }
171
- break;
173
+ claimRewards(tutorial._id);
174
+ updateActiveTutorial(null);
175
+ setIsTourOpen(false);
172
176
  }
177
+ } else {
178
+ console.log(`[Tutoriels] Étape ${currentStageNumber} non complétée.`);
173
179
  }
174
180
  } catch (error) {
175
- console.error("[Tutoriels] Erreur lors de la vérification en chaîne :", error);
181
+ console.error("[Tutoriels] Erreur lors de la vérification de l'étape :", error);
176
182
  } finally {
177
- isCheckingRef.current = false;
178
- console.log('[Tutoriels] Vérification en chaîne terminée, verrou libéré.');
179
-
180
- // Si une vérification a été mise en attente, on la lance maintenant.
181
- if (isCheckQueuedRef.current) {
182
- console.log('[Tutoriels] Lancement de la vérification mise en file d\'attente.');
183
- isCheckQueuedRef.current = false;
184
- triggerTutorialCheck();
185
- }
183
+ // On libère le verrou
184
+ completingStepRef.current = null;
185
+ console.log(`[Tutoriels] Libération du verrou pour l'étape ${stepIdentifier}`);
186
186
  }
187
- }, [
188
- tutorials, checkCompletionCondition, claimRewards, updateActiveTutorial,
189
- addNotification, launchTour, t, setIsTourOpen, me
190
- ]);
187
+ }, [me, tutorials, checkCompletionCondition, updateActiveTutorial, claimRewards, addNotification, launchTour, t, setIsTourOpen]);
191
188
 
192
189
  useEffect(() => {
193
190
  setMe(prevMe => ({ ...prevMe, activeTutorial: activeTuto }));
194
191
  }, [activeTuto]);
195
192
 
193
+ const handleDataChange = useCallback(async (payload) => {
194
+ if (me?.activeTutorial) {
195
+ console.log(`[Tutoriels] Événement de données reçu, déclenchement de la vérification.`, payload);
196
+ triggerTutorialCheck();
197
+ }
198
+ }, [me?.activeTutorial, triggerTutorialCheck]);
199
+
196
200
  // Écoute les événements de modification de données pour vérifier la progression en arrière-plan.
197
201
  useEffect(() => {
198
- const handleDataChange = async (payload) => {
199
- console.log(`[Tutoriels] Événement de données reçu.`, payload);
200
- if (me?.activeTutorial) {
201
- triggerTutorialCheck();
202
- }
203
- };
204
-
205
- console.log(me.activeTutorial + new Date().getMilliseconds());
206
202
  const eventTypes = ['API_ADD_DATA', 'API_EDIT_DATA', 'API_DELETE_DATA'];
207
203
  eventTypes.forEach(type => Event.Listen(type, handleDataChange, "custom", "data"));
208
204
 
209
205
  return () => {
210
206
  eventTypes.forEach(type => Event.RemoveCallback(type, handleDataChange, "custom", "data"));
211
207
  };
212
- }, [me?.activeTutorial]);
208
+ }, [handleDataChange]);
213
209
 
214
210
  // Vérifie la progression lorsqu'un tutoriel devient actif ou que son étape change.
215
211
  useEffect(() => {
216
212
  triggerTutorialCheck();
217
- },[]);
213
+ },[me?.activeTutorial]); // Se déclenche quand le tutoriel/étape change
214
+
218
215
  return {
219
216
  tutorials,
220
217
  isLoadingTutorials,
@@ -2,6 +2,8 @@
2
2
  export const websiteTranslations = {
3
3
  fr: {
4
4
  translation: {
5
+ "dashboards.addKpi": "Ajouter un indicateur KPI",
6
+
5
7
  "field.geolocation": "Géolocalisation",
6
8
  "time.minute": "Minute",
7
9
  "time.hour": "Heure",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "data-primals-engine",
3
- "version": "1.4.3",
3
+ "version": "1.5.0",
4
4
  "description": "data-primals-engine is a package responsible from handling large amount of data using MongoDB in a practical and performant way. It can also get workflow models working (for automation), and fully supports internationalisation. It also has integrated AI assistant.",
5
5
  "main": "src/engine.js",
6
6
  "type": "module",
@@ -101,6 +101,7 @@
101
101
  "stripe": "^18.4.0",
102
102
  "swagger-ui-express": "^5.0.1",
103
103
  "tar": "^7.4.3",
104
+ "tinycolor2": "^1.6.0",
104
105
  "uniqid": "^5.4.0",
105
106
  "vitest": "^3.2.4",
106
107
  "yaml": "^2.8.0"
package/perf/README.md ADDED
@@ -0,0 +1,147 @@
1
+ ## Performance Test Report 1
2
+
3
+ ### Test Environment
4
+ - **Machine**: Laptop Intel Core i7 Lenovo Legion
5
+ - **Concurrency**: Ramp-up to 50 virtual users/sec
6
+ - Runs on `perf-shot-search.yml` ,that simulates user basic activity on API
7
+
8
+ ### Summary of Results
9
+
10
+ | Metric | Value |
11
+ |----------------------------|------------|
12
+ | Total HTTP Requests | 1,475 |
13
+ | Successful Responses (2xx) | 1,475 |
14
+ | Average Request Rate | 19/sec |
15
+ | Data Downloaded | 13.5 MB |
16
+ | Failed Scenarios | 0 |
17
+
18
+ ### Response Time Analysis (in milliseconds)
19
+
20
+ | Percentile | Response Time (ms) |
21
+ |------------|--------------------|
22
+ | min | 8 |
23
+ | median | **36.2** |
24
+ | mean | 253.9 |
25
+ | p95 | 561.2 |
26
+ | p99 | 608 |
27
+ | max | 805 |
28
+ ---
29
+
30
+ ### Analysis & Recommendations
31
+
32
+ * **Outstanding Performance & Stability**: The final test results are excellent. The server handled **1,475 requests with 0 failures**, demonstrating exceptional stability under sustained load.
33
+
34
+ * **Bottleneck Resolved**:
35
+ * The **median response time is now just 36.2 milliseconds**. This is a dramatic improvement and indicates that the core API operations are extremely fast for the majority of users.
36
+ * The **p95 (561.2 ms)** and **p99 (608 ms)** response times remain well under one second. This shows that the application performs consistently, with very few outliers, even during peak load phases.
37
+
38
+
39
+
40
+ ## Performance Test Report 2
41
+
42
+ ### Test Environment
43
+ - **Machine**: Laptop Intel Core i7 Lenovo Legion
44
+ - **Scenario**: `perf-shot-hardwork.yml` (Full user journey including model creation, data import, and pack installation)
45
+ - **Concurrency**: Sustained load of 1 virtual user/sec for 60 seconds.
46
+
47
+ ### Summary of Results
48
+
49
+ | Metric | Value |
50
+ |---|---|
51
+ | Total HTTP Requests | 480 |
52
+ | Successful Responses (2xx) | 480 |
53
+ | Average Request Rate | 4/sec |
54
+ | Data Downloaded | 0.88 MB |
55
+ | **Failed Scenarios** | **0** |
56
+
57
+ ### Response Time Analysis (in milliseconds)
58
+
59
+ | Percentile | Response Time (ms) |
60
+ |------------|--------------------|
61
+ | min | 7 |
62
+ | median | **15** |
63
+ | mean | 96.1 |
64
+ | p95 | 368.8 |
65
+ | p99 | 407.5 |
66
+ | max | 474 |
67
+ ---
68
+
69
+ ### Analysis & Recommendations
70
+
71
+ * **Total Success & Stability**: The test is a complete success. With a 100% success rate (**0 failed scenarios** and **0 request errors**), it confirms that the previous fixes for race conditions and file upload issues are effective and robust.
72
+
73
+ * **Outstanding Performance**: The API's responsiveness is excellent.
74
+ * A **median response time of just 15 milliseconds** shows that the core operations are extremely fast for the majority of users.
75
+ * The **p99 is 407.5 ms**, meaning 99% of requests, even within a complex multi-step scenario, completed in well under half a second. This demonstrates consistent and reliable performance.
76
+
77
+ * **Conclusion**: The application is perfectly stable and highly performant under the tested load. The entire user journey, including complex operations, is handled efficiently. This provides a solid baseline for future, more intensive load tests.
78
+
79
+
80
+ ## Performance Test Report 3 (Scaling)
81
+
82
+ ### Test Environment
83
+ - **Machine**: Laptop Intel Core i7 Lenovo Legion
84
+ - **Scenario**: `perf-shot-hardwork.yml` (Full user journey)
85
+ - **Concurrency**: Ramped load from 1 to 2 virtual users/sec for 60 seconds.
86
+
87
+ ### Summary of Results
88
+
89
+ | Metric | Value |
90
+ |---|---|
91
+ | Total HTTP Requests | 720 |
92
+ | Successful Responses (2xx) | 720 |
93
+ | Average Request Rate | 9/sec |
94
+ | **Failed Scenarios** | **0** |
95
+
96
+ ### Response Time Analysis (in milliseconds)
97
+
98
+ | Percentile | Response Time (ms) |
99
+ |------------|--------------------|
100
+ | min | 8 |
101
+ | median | **32.1** |
102
+ | mean | 141.4 |
103
+ | p95 | 772.9 |
104
+ | p99 | 854.2 |
105
+ | max | 922 |
106
+ ---
107
+
108
+ ### Analysis & Recommendations
109
+
110
+ * **Excellent Linear Scaling**: This test confirms the application's ability to scale gracefully. When doubling the load from the previous test (1 user/sec to 2 users/sec), the median and p95 response times also roughly doubled. This linear behavior is ideal and indicates a robust architecture without major bottlenecks.
111
+ * **Continued Stability**: With **0 failed scenarios**, the application proves its stability even as concurrency increases.
112
+ * **Conclusion**: The application is ready for higher loads. The performance degradation is predictable and linear, which is a sign of a very healthy system.
113
+
114
+
115
+ ## Performance Test Report 4 (Bottleneck Identification)
116
+
117
+ ### Test Environment
118
+ - **Machine**: Laptop Intel Core i7 Lenovo Legion
119
+ - **Scenario**: `perf-shot-hardwork.yml` (Full user journey)
120
+ - **Concurrency**: Ramped load from 1 to 4 virtual users/sec for 60 seconds.
121
+
122
+ ### Summary of Results
123
+
124
+ | Metric | Value |
125
+ |---|---|
126
+ | Total HTTP Requests | 1,200 |
127
+ | Successful Responses (2xx) | 1,200 |
128
+ | Average Request Rate | 12/sec |
129
+ | **Failed Scenarios** | **0** |
130
+
131
+ ### Response Time Analysis (in milliseconds)
132
+
133
+ | Percentile | Response Time (ms) |
134
+ |------------|--------------------|
135
+ | min | 3 |
136
+ | median | **70.1** |
137
+ | mean | 483.9 |
138
+ | p95 | **2893.5** |
139
+ | p99 | **8024.5** |
140
+ | max | 8348 |
141
+ ---
142
+
143
+ ### Analysis & Recommendations
144
+
145
+ * **Bottleneck Identified**: This test successfully identified the application's first major performance bottleneck. While stability remains perfect (**0 failures**), the response times show non-linear degradation. The **p99 jumping to over 8 seconds** indicates that the system is struggling under this write-heavy load.
146
+ * **Database Contention**: The cause is almost certainly database contention (MongoDB). The scenario involves concurrent model creation, data import, and pack installation, which are highly resource-intensive operations.
147
+ * **Next Steps**: The immediate next step is to run a detailed analysis to pinpoint the exact slow queries. Monitoring server CPU/Disk I/O during the test and using MongoDB's profiler will be essential for optimization.
@@ -0,0 +1,37 @@
1
+ import {uuidv4} from "../src/core.js";
2
+
3
+
4
+ export function setScenarioVariables(context, events, done) {
5
+ // context.vars is where we store variables for the virtual user's session
6
+ context.vars.vuId = uuidv4().replace(/-/g, '');
7
+ context.vars.demoUser = `perf${Math.floor(Math.random() * 9000000) + 1000000}`;
8
+ return done();
9
+ }
10
+ // Hook to name requests to aggregate metrics by endpoint
11
+ export function metricsByEndpoint_beforeRequest(requestParams, context, ee, next) {
12
+ // Remove query parameters for the metric name
13
+ const urlPath = requestParams.url.split('?')[0];
14
+
15
+ // Replace unique IDs (ObjectId or UUID) in the URL with a placeholder for aggregation
16
+ // Example: /api/data/60d0fe4f5311236168a109ca -> /api/data/:id
17
+ const name = urlPath.replace(/([a-fA-F0-9]{24}|[a-fA-F0-9-]{36})/g, ':id');
18
+
19
+ // Emit an internal Artillery event to name the request
20
+ ee.emit('request:set_name', name);
21
+
22
+ return next();
23
+ }
24
+
25
+ // Hook pour logger les erreurs de manière détaillée
26
+ export function metricsByEndpoint_afterResponse(requestParams, response, context, ee, next) {
27
+ // On vérifie si le statut est un code d'erreur (4xx ou 5xx)
28
+ if (response.statusCode >= 400) {
29
+ console.error(`\n[ERREUR] Requête ${requestParams.method} ${requestParams.url} a échoué avec le statut ${response.statusCode}`);
30
+ // On log le corps de la réponse, qui contient souvent le message d'erreur du serveur
31
+ if (response.body) {
32
+ console.error(` -> Réponse: ${response.body}\n`);
33
+ }
34
+ }
35
+ // On passe à la suite du scénario
36
+ return next();
37
+ }