@miragon/pulse-analytics-connector 0.0.0-bootstrap.0 → 0.21.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 (357) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -3
  3. package/dist/bpmn-name-lookup.d.ts +72 -0
  4. package/dist/bpmn-name-lookup.d.ts.map +1 -0
  5. package/dist/bpmn-name-lookup.js +123 -0
  6. package/dist/bpmn-name-lookup.js.map +1 -0
  7. package/dist/bpmn-name-lookup.test.d.ts +2 -0
  8. package/dist/bpmn-name-lookup.test.d.ts.map +1 -0
  9. package/dist/bpmn-name-lookup.test.js +287 -0
  10. package/dist/bpmn-name-lookup.test.js.map +1 -0
  11. package/dist/bpmn-names.d.ts +38 -0
  12. package/dist/bpmn-names.d.ts.map +1 -0
  13. package/dist/bpmn-names.js +22 -0
  14. package/dist/bpmn-names.js.map +1 -0
  15. package/dist/brand-gates.test.d.ts +2 -0
  16. package/dist/brand-gates.test.d.ts.map +1 -0
  17. package/dist/brand-gates.test.js +58 -0
  18. package/dist/brand-gates.test.js.map +1 -0
  19. package/dist/builder-texts.d.ts +4 -0
  20. package/dist/builder-texts.d.ts.map +1 -0
  21. package/dist/builder-texts.js +14 -0
  22. package/dist/builder-texts.js.map +1 -0
  23. package/dist/builder-texts.test.d.ts +2 -0
  24. package/dist/builder-texts.test.d.ts.map +1 -0
  25. package/dist/builder-texts.test.js +32 -0
  26. package/dist/builder-texts.test.js.map +1 -0
  27. package/dist/definition.d.ts +3 -0
  28. package/dist/definition.d.ts.map +1 -0
  29. package/dist/definition.js +151 -0
  30. package/dist/definition.js.map +1 -0
  31. package/dist/engine-ids.d.ts +42 -0
  32. package/dist/engine-ids.d.ts.map +1 -0
  33. package/dist/engine-ids.js +50 -0
  34. package/dist/engine-ids.js.map +1 -0
  35. package/dist/engine-scope.test.d.ts +2 -0
  36. package/dist/engine-scope.test.d.ts.map +1 -0
  37. package/dist/engine-scope.test.js +260 -0
  38. package/dist/engine-scope.test.js.map +1 -0
  39. package/dist/heatmap-data.d.ts +15 -0
  40. package/dist/heatmap-data.d.ts.map +1 -0
  41. package/dist/heatmap-data.js +2 -0
  42. package/dist/heatmap-data.js.map +1 -0
  43. package/dist/index.d.ts +5 -0
  44. package/dist/index.d.ts.map +1 -0
  45. package/dist/index.js +3 -0
  46. package/dist/index.js.map +1 -0
  47. package/dist/instructions.d.ts +8 -0
  48. package/dist/instructions.d.ts.map +1 -0
  49. package/dist/instructions.js +33 -0
  50. package/dist/instructions.js.map +1 -0
  51. package/dist/messages/de.ask-ai.d.ts +9 -0
  52. package/dist/messages/de.ask-ai.d.ts.map +1 -0
  53. package/dist/messages/de.ask-ai.js +18 -0
  54. package/dist/messages/de.ask-ai.js.map +1 -0
  55. package/dist/messages/de.builder.d.ts +12 -0
  56. package/dist/messages/de.builder.d.ts.map +1 -0
  57. package/dist/messages/de.builder.js +53 -0
  58. package/dist/messages/de.builder.js.map +1 -0
  59. package/dist/messages/de.server.d.ts +8 -0
  60. package/dist/messages/de.server.d.ts.map +1 -0
  61. package/dist/messages/de.server.js +26 -0
  62. package/dist/messages/de.server.js.map +1 -0
  63. package/dist/messages/de.sweep.d.ts +10 -0
  64. package/dist/messages/de.sweep.d.ts.map +1 -0
  65. package/dist/messages/de.sweep.js +203 -0
  66. package/dist/messages/de.sweep.js.map +1 -0
  67. package/dist/messages/en.ask-ai.d.ts +25 -0
  68. package/dist/messages/en.ask-ai.d.ts.map +1 -0
  69. package/dist/messages/en.ask-ai.js +24 -0
  70. package/dist/messages/en.ask-ai.js.map +1 -0
  71. package/dist/messages/en.builder.d.ts +12 -0
  72. package/dist/messages/en.builder.d.ts.map +1 -0
  73. package/dist/messages/en.builder.js +53 -0
  74. package/dist/messages/en.builder.js.map +1 -0
  75. package/dist/messages/en.server.d.ts +8 -0
  76. package/dist/messages/en.server.d.ts.map +1 -0
  77. package/dist/messages/en.server.js +26 -0
  78. package/dist/messages/en.server.js.map +1 -0
  79. package/dist/messages/en.sweep.d.ts +11 -0
  80. package/dist/messages/en.sweep.d.ts.map +1 -0
  81. package/dist/messages/en.sweep.js +204 -0
  82. package/dist/messages/en.sweep.js.map +1 -0
  83. package/dist/messages/glossary.d.ts +26 -0
  84. package/dist/messages/glossary.d.ts.map +1 -0
  85. package/dist/messages/glossary.js +37 -0
  86. package/dist/messages/glossary.js.map +1 -0
  87. package/dist/messages/glossary.test.d.ts +2 -0
  88. package/dist/messages/glossary.test.d.ts.map +1 -0
  89. package/dist/messages/glossary.test.js +109 -0
  90. package/dist/messages/glossary.test.js.map +1 -0
  91. package/dist/messages/index.d.ts +12 -0
  92. package/dist/messages/index.d.ts.map +1 -0
  93. package/dist/messages/index.js +23 -0
  94. package/dist/messages/index.js.map +1 -0
  95. package/dist/messages/plural.d.ts +10 -0
  96. package/dist/messages/plural.d.ts.map +1 -0
  97. package/dist/messages/plural.js +17 -0
  98. package/dist/messages/plural.js.map +1 -0
  99. package/dist/messages/use-t.d.ts +10 -0
  100. package/dist/messages/use-t.d.ts.map +1 -0
  101. package/dist/messages/use-t.js +10 -0
  102. package/dist/messages/use-t.js.map +1 -0
  103. package/dist/module.d.ts +48 -0
  104. package/dist/module.d.ts.map +1 -0
  105. package/dist/module.js +174 -0
  106. package/dist/module.js.map +1 -0
  107. package/dist/module.test.d.ts +2 -0
  108. package/dist/module.test.d.ts.map +1 -0
  109. package/dist/module.test.js +230 -0
  110. package/dist/module.test.js.map +1 -0
  111. package/dist/named-views.d.ts +27 -0
  112. package/dist/named-views.d.ts.map +1 -0
  113. package/dist/named-views.js +73 -0
  114. package/dist/named-views.js.map +1 -0
  115. package/dist/named-views.test.d.ts +2 -0
  116. package/dist/named-views.test.d.ts.map +1 -0
  117. package/dist/named-views.test.js +110 -0
  118. package/dist/named-views.test.js.map +1 -0
  119. package/dist/plugin.d.ts +44 -0
  120. package/dist/plugin.d.ts.map +1 -0
  121. package/dist/plugin.js +52 -0
  122. package/dist/plugin.js.map +1 -0
  123. package/dist/prometheus-wiring.test.d.ts +2 -0
  124. package/dist/prometheus-wiring.test.d.ts.map +1 -0
  125. package/dist/prometheus-wiring.test.js +142 -0
  126. package/dist/prometheus-wiring.test.js.map +1 -0
  127. package/dist/server-locale.d.ts +48 -0
  128. package/dist/server-locale.d.ts.map +1 -0
  129. package/dist/server-locale.js +33 -0
  130. package/dist/server-locale.js.map +1 -0
  131. package/dist/settings-tools.d.ts +23 -0
  132. package/dist/settings-tools.d.ts.map +1 -0
  133. package/dist/settings-tools.js +100 -0
  134. package/dist/settings-tools.js.map +1 -0
  135. package/dist/settings.d.ts +77 -0
  136. package/dist/settings.d.ts.map +1 -0
  137. package/dist/settings.js +99 -0
  138. package/dist/settings.js.map +1 -0
  139. package/dist/settings.test.d.ts +2 -0
  140. package/dist/settings.test.d.ts.map +1 -0
  141. package/dist/settings.test.js +299 -0
  142. package/dist/settings.test.js.map +1 -0
  143. package/dist/steps/app-config.d.ts +30 -0
  144. package/dist/steps/app-config.d.ts.map +1 -0
  145. package/dist/steps/app-config.js +46 -0
  146. package/dist/steps/app-config.js.map +1 -0
  147. package/dist/steps/dashboard.d.ts +12 -0
  148. package/dist/steps/dashboard.d.ts.map +1 -0
  149. package/dist/steps/dashboard.js +58 -0
  150. package/dist/steps/dashboard.js.map +1 -0
  151. package/dist/steps/failure-dashboard.d.ts +10 -0
  152. package/dist/steps/failure-dashboard.d.ts.map +1 -0
  153. package/dist/steps/failure-dashboard.js +32 -0
  154. package/dist/steps/failure-dashboard.js.map +1 -0
  155. package/dist/steps/index.d.ts +3 -0
  156. package/dist/steps/index.d.ts.map +1 -0
  157. package/dist/steps/index.js +3 -0
  158. package/dist/steps/index.js.map +1 -0
  159. package/dist/steps/steps.test.d.ts +2 -0
  160. package/dist/steps/steps.test.d.ts.map +1 -0
  161. package/dist/steps/steps.test.js +153 -0
  162. package/dist/steps/steps.test.js.map +1 -0
  163. package/dist/tool-names.d.ts +13 -0
  164. package/dist/tool-names.d.ts.map +1 -0
  165. package/dist/tool-names.js +27 -0
  166. package/dist/tool-names.js.map +1 -0
  167. package/dist/tools/cluster-compare.d.ts +8 -0
  168. package/dist/tools/cluster-compare.d.ts.map +1 -0
  169. package/dist/tools/cluster-compare.js +17 -0
  170. package/dist/tools/cluster-compare.js.map +1 -0
  171. package/dist/tools/element.d.ts +8 -0
  172. package/dist/tools/element.d.ts.map +1 -0
  173. package/dist/tools/element.js +25 -0
  174. package/dist/tools/element.js.map +1 -0
  175. package/dist/tools/engine-compare.d.ts +8 -0
  176. package/dist/tools/engine-compare.d.ts.map +1 -0
  177. package/dist/tools/engine-compare.js +18 -0
  178. package/dist/tools/engine-compare.js.map +1 -0
  179. package/dist/tools/engine-compare.test.d.ts +2 -0
  180. package/dist/tools/engine-compare.test.d.ts.map +1 -0
  181. package/dist/tools/engine-compare.test.js +166 -0
  182. package/dist/tools/engine-compare.test.js.map +1 -0
  183. package/dist/tools/engine-landscape.d.ts +7 -0
  184. package/dist/tools/engine-landscape.d.ts.map +1 -0
  185. package/dist/tools/engine-landscape.js +13 -0
  186. package/dist/tools/engine-landscape.js.map +1 -0
  187. package/dist/tools/engine-landscape.test.d.ts +2 -0
  188. package/dist/tools/engine-landscape.test.d.ts.map +1 -0
  189. package/dist/tools/engine-landscape.test.js +110 -0
  190. package/dist/tools/engine-landscape.test.js.map +1 -0
  191. package/dist/tools/failures.d.ts +7 -0
  192. package/dist/tools/failures.d.ts.map +1 -0
  193. package/dist/tools/failures.js +13 -0
  194. package/dist/tools/failures.js.map +1 -0
  195. package/dist/tools/health.d.ts +7 -0
  196. package/dist/tools/health.d.ts.map +1 -0
  197. package/dist/tools/health.js +23 -0
  198. package/dist/tools/health.js.map +1 -0
  199. package/dist/tools/health.test.d.ts +2 -0
  200. package/dist/tools/health.test.d.ts.map +1 -0
  201. package/dist/tools/health.test.js +119 -0
  202. package/dist/tools/health.test.js.map +1 -0
  203. package/dist/tools/index.d.ts +18 -0
  204. package/dist/tools/index.d.ts.map +1 -0
  205. package/dist/tools/index.js +35 -0
  206. package/dist/tools/index.js.map +1 -0
  207. package/dist/tools/performance.d.ts +8 -0
  208. package/dist/tools/performance.d.ts.map +1 -0
  209. package/dist/tools/performance.js +25 -0
  210. package/dist/tools/performance.js.map +1 -0
  211. package/dist/tools/performance.test.d.ts +2 -0
  212. package/dist/tools/performance.test.d.ts.map +1 -0
  213. package/dist/tools/performance.test.js +176 -0
  214. package/dist/tools/performance.test.js.map +1 -0
  215. package/dist/tools/version-compare.d.ts +8 -0
  216. package/dist/tools/version-compare.d.ts.map +1 -0
  217. package/dist/tools/version-compare.js +17 -0
  218. package/dist/tools/version-compare.js.map +1 -0
  219. package/dist/tools/version-compare.test.d.ts +2 -0
  220. package/dist/tools/version-compare.test.d.ts.map +1 -0
  221. package/dist/tools/version-compare.test.js +82 -0
  222. package/dist/tools/version-compare.test.js.map +1 -0
  223. package/dist/toolsets.d.ts +17 -0
  224. package/dist/toolsets.d.ts.map +1 -0
  225. package/dist/toolsets.js +47 -0
  226. package/dist/toolsets.js.map +1 -0
  227. package/dist/toolsets.test.d.ts +2 -0
  228. package/dist/toolsets.test.d.ts.map +1 -0
  229. package/dist/toolsets.test.js +91 -0
  230. package/dist/toolsets.test.js.map +1 -0
  231. package/dist/view-title.test.d.ts +2 -0
  232. package/dist/view-title.test.d.ts.map +1 -0
  233. package/dist/view-title.test.js +91 -0
  234. package/dist/view-title.test.js.map +1 -0
  235. package/dist/widget-tools/comparisons.d.ts +13 -0
  236. package/dist/widget-tools/comparisons.d.ts.map +1 -0
  237. package/dist/widget-tools/comparisons.js +177 -0
  238. package/dist/widget-tools/comparisons.js.map +1 -0
  239. package/dist/widget-tools/comparisons.test.d.ts +2 -0
  240. package/dist/widget-tools/comparisons.test.d.ts.map +1 -0
  241. package/dist/widget-tools/comparisons.test.js +123 -0
  242. package/dist/widget-tools/comparisons.test.js.map +1 -0
  243. package/dist/widget-tools/shared.d.ts +36 -0
  244. package/dist/widget-tools/shared.d.ts.map +1 -0
  245. package/dist/widget-tools/shared.js +39 -0
  246. package/dist/widget-tools/shared.js.map +1 -0
  247. package/dist/widget-tools.d.ts +25 -0
  248. package/dist/widget-tools.d.ts.map +1 -0
  249. package/dist/widget-tools.js +269 -0
  250. package/dist/widget-tools.js.map +1 -0
  251. package/dist/widget-tools.test.d.ts +2 -0
  252. package/dist/widget-tools.test.d.ts.map +1 -0
  253. package/dist/widget-tools.test.js +272 -0
  254. package/dist/widget-tools.test.js.map +1 -0
  255. package/package.json +91 -5
  256. package/src/bpmn-name-lookup.test.ts +333 -0
  257. package/src/bpmn-name-lookup.ts +197 -0
  258. package/src/bpmn-names.ts +51 -0
  259. package/src/brand-gates.test.ts +69 -0
  260. package/src/builder-texts.test.ts +40 -0
  261. package/src/builder-texts.ts +15 -0
  262. package/src/definition.ts +181 -0
  263. package/src/engine-ids.ts +85 -0
  264. package/src/engine-scope.test.ts +324 -0
  265. package/src/heatmap-data.ts +14 -0
  266. package/src/index.ts +4 -0
  267. package/src/instructions.ts +33 -0
  268. package/src/messages/GLOSSARY.md +88 -0
  269. package/src/messages/de.ask-ai.ts +28 -0
  270. package/src/messages/de.builder.ts +77 -0
  271. package/src/messages/de.server.ts +39 -0
  272. package/src/messages/de.sweep.ts +244 -0
  273. package/src/messages/en.ask-ai.ts +34 -0
  274. package/src/messages/en.builder.ts +76 -0
  275. package/src/messages/en.server.ts +39 -0
  276. package/src/messages/en.sweep.ts +240 -0
  277. package/src/messages/glossary.test.ts +122 -0
  278. package/src/messages/glossary.ts +62 -0
  279. package/src/messages/index.ts +24 -0
  280. package/src/messages/plural.ts +18 -0
  281. package/src/messages/use-t.ts +12 -0
  282. package/src/module.test.ts +319 -0
  283. package/src/module.ts +211 -0
  284. package/src/named-views.test.ts +141 -0
  285. package/src/named-views.ts +97 -0
  286. package/src/plugin.ts +94 -0
  287. package/src/prometheus-wiring.test.ts +174 -0
  288. package/src/server-locale.ts +59 -0
  289. package/src/settings-tools.ts +170 -0
  290. package/src/settings.test.ts +402 -0
  291. package/src/settings.ts +114 -0
  292. package/src/steps/app-config.ts +75 -0
  293. package/src/steps/dashboard.ts +73 -0
  294. package/src/steps/failure-dashboard.ts +38 -0
  295. package/src/steps/index.ts +2 -0
  296. package/src/steps/steps.test.ts +216 -0
  297. package/src/tool-names.ts +26 -0
  298. package/src/tools/cluster-compare.ts +28 -0
  299. package/src/tools/element.ts +35 -0
  300. package/src/tools/engine-compare.test.ts +210 -0
  301. package/src/tools/engine-compare.ts +30 -0
  302. package/src/tools/engine-landscape.test.ts +138 -0
  303. package/src/tools/engine-landscape.ts +21 -0
  304. package/src/tools/failures.ts +19 -0
  305. package/src/tools/health.test.ts +156 -0
  306. package/src/tools/health.ts +29 -0
  307. package/src/tools/index.ts +44 -0
  308. package/src/tools/performance.test.ts +210 -0
  309. package/src/tools/performance.ts +38 -0
  310. package/src/tools/version-compare.test.ts +97 -0
  311. package/src/tools/version-compare.ts +28 -0
  312. package/src/toolsets.test.ts +107 -0
  313. package/src/toolsets.ts +56 -0
  314. package/src/view-title.test.ts +123 -0
  315. package/src/widget-tools/comparisons.test.ts +170 -0
  316. package/src/widget-tools/comparisons.ts +229 -0
  317. package/src/widget-tools/shared.ts +67 -0
  318. package/src/widget-tools.test.ts +306 -0
  319. package/src/widget-tools.ts +375 -0
  320. package/src/widgets/analytics-dashboard/activity-bottleneck-table.tsx +179 -0
  321. package/src/widgets/analytics-dashboard/definition-breakdown.tsx +108 -0
  322. package/src/widgets/analytics-dashboard/execution-performance-kpi.tsx +94 -0
  323. package/src/widgets/analytics-dashboard/execution-summary-kpi.tsx +122 -0
  324. package/src/widgets/analytics-dashboard/lib.ts +43 -0
  325. package/src/widgets/bpmn-heatmap.test.tsx +75 -0
  326. package/src/widgets/bpmn-heatmap.tsx +127 -0
  327. package/src/widgets/catalogue-sync.test.ts +16 -0
  328. package/src/widgets/cluster-compare.tsx +148 -0
  329. package/src/widgets/comparison-shared.test.ts +237 -0
  330. package/src/widgets/comparison-shared.tsx +371 -0
  331. package/src/widgets/comparison-widgets.test.tsx +307 -0
  332. package/src/widgets/dashboard-widgets.test.tsx +397 -0
  333. package/src/widgets/engine-compare.tsx +87 -0
  334. package/src/widgets/engine-landscape.tsx +352 -0
  335. package/src/widgets/failure-dashboard/error-patterns-table.tsx +122 -0
  336. package/src/widgets/failure-dashboard/failure-rate-table.test.ts +80 -0
  337. package/src/widgets/failure-dashboard/failure-rate-table.tsx +170 -0
  338. package/src/widgets/failure-dashboard/lib.ts +32 -0
  339. package/src/widgets/failure-dashboard/summary-kpi.tsx +122 -0
  340. package/src/widgets/failure-landscape-widgets.test.tsx +393 -0
  341. package/src/widgets/figure.tsx +79 -0
  342. package/src/widgets/hand-off.test.ts +126 -0
  343. package/src/widgets/hand-off.ts +102 -0
  344. package/src/widgets/index.ts +124 -0
  345. package/src/widgets/model-descriptions.dashboard.test.ts +280 -0
  346. package/src/widgets/model-descriptions.test.ts +251 -0
  347. package/src/widgets/model-descriptions.ts +428 -0
  348. package/src/widgets/names.test.ts +32 -0
  349. package/src/widgets/names.ts +39 -0
  350. package/src/widgets/query-gate.test.tsx +57 -0
  351. package/src/widgets/query-gate.tsx +50 -0
  352. package/src/widgets/settings-section.test.tsx +174 -0
  353. package/src/widgets/settings-section.tsx +244 -0
  354. package/src/widgets/version-compare.test.ts +68 -0
  355. package/src/widgets/version-compare.tsx +96 -0
  356. package/src/widgets/view-meta.ts +18 -0
  357. package/src/widgets/widget-render.test-support.tsx +103 -0
@@ -0,0 +1,237 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import type { CompareKpiDelta } from "@miragon/pulse-analytics-client"
3
+ import { setFormatLocale } from "@miragon/pulse-widget-shell/testing"
4
+ import { translator } from "../messages/index.js"
5
+ import type { T } from "../messages/use-t.js"
6
+ import {
7
+ DURATION_MIN_CHANGE_PCT,
8
+ RATE_MIN_CHANGE,
9
+ buildComparisonMetrics,
10
+ deltaFacts,
11
+ deltaVerdict,
12
+ formatDelta,
13
+ type DeltaRule,
14
+ } from "./comparison-shared.js"
15
+
16
+ afterEach(() => setFormatLocale(undefined))
17
+
18
+ const english = () => setFormatLocale({ language: "en", locale: "en-US" })
19
+ const german = () => setFormatLocale({ language: "de", locale: "de-DE" })
20
+ const en: T = (key, params) => translator("en", key, params)
21
+ const de: T = (key, params) => translator("de", key, params)
22
+
23
+ /** The formatters keep a number and its unit together with a non-breaking space. */
24
+ const plain = (s: string) => s.replace(/\u00a0/g, " ")
25
+
26
+ type Kpis = Parameters<typeof buildComparisonMetrics>[1]
27
+
28
+ const kpis = (over: Partial<Kpis> = {}): Kpis => ({
29
+ instance_count: 1240,
30
+ incident_rate_pct: 5,
31
+ element_incident_count: null,
32
+ element_incident_rate_pct: null,
33
+ avg_duration_sec: 60,
34
+ p95_duration_sec: 187,
35
+ ...over,
36
+ })
37
+
38
+ const delta: CompareKpiDelta = {
39
+ started_per_day_delta_pct: 10,
40
+ incident_rate_delta_pp: -2,
41
+ element_incident_rate_delta_pp: null,
42
+ avg_duration_delta_pct: 5,
43
+ p95_duration_delta_pct: null,
44
+ }
45
+
46
+ const row = (t: T, metrics: ReturnType<typeof buildComparisonMetrics>, labelKey: string) =>
47
+ metrics.find((m) => m.label === t(labelKey))
48
+
49
+ const VOLUME: DeltaRule = { kind: "volume" }
50
+ const DURATION: DeltaRule = { kind: "quality", worseIfUp: true, minChange: DURATION_MIN_CHANGE_PCT }
51
+ const RATE: DeltaRule = { kind: "quality", worseIfUp: true, minChange: RATE_MIN_CHANGE }
52
+
53
+ describe("buildComparisonMetrics", () => {
54
+ it("formats the values in the view's locale and keeps the raw deltas with their rule", () => {
55
+ english()
56
+ const metrics = buildComparisonMetrics(en, kpis(), kpis({ incident_rate_pct: 3 }), delta)
57
+
58
+ expect(row(en, metrics, "aComparison.metricIncidentRate")).toEqual({
59
+ label: "Incidents per 100 starts",
60
+ before: "5.0",
61
+ after: "3.0",
62
+ delta: { value: -2, unit: "rate", rule: RATE },
63
+ })
64
+ // The count per window, the delta per day: fair across unequal windows,
65
+ // and a volume, so it carries no verdict rule.
66
+ expect(row(en, metrics, "aComparison.metricStarted")).toMatchObject({
67
+ label: "Starts (change per day)",
68
+ before: "1,240",
69
+ delta: { value: 10, unit: "pct", rule: VOLUME },
70
+ })
71
+ expect(row(en, metrics, "aComparison.metricAvgDuration")).toMatchObject({
72
+ before: "1m 0s",
73
+ delta: { value: 5, unit: "pct", rule: DURATION },
74
+ })
75
+ expect(row(en, metrics, "aComparison.metricP95Duration")).toMatchObject({
76
+ before: "3m 7s",
77
+ delta: { value: null },
78
+ })
79
+ })
80
+
81
+ it("speaks German: grouped digits, decimal comma, German units and labels", () => {
82
+ german()
83
+ const metrics = buildComparisonMetrics(de, kpis(), kpis({ incident_rate_pct: 3.25 }), delta)
84
+ expect(row(de, metrics, "aComparison.metricStarted")).toMatchObject({
85
+ label: "Starts (Änderung je Tag)",
86
+ before: "1.240",
87
+ })
88
+ expect(row(de, metrics, "aComparison.metricIncidentRate")).toMatchObject({
89
+ label: "Incidents je 100 Starts",
90
+ before: "5,0",
91
+ after: "3,3",
92
+ })
93
+ expect(plain(row(de, metrics, "aComparison.metricP95Duration")!.before)).toBe("3 Min. 7 s")
94
+ })
95
+
96
+ it("renders unmeasured rates as 'not measured' with no delta, never as 0 (#327)", () => {
97
+ english()
98
+ const unmeasured = kpis({ incident_rate_pct: null })
99
+ const metrics = buildComparisonMetrics(en, unmeasured, unmeasured, {
100
+ ...delta,
101
+ incident_rate_delta_pp: null,
102
+ })
103
+
104
+ expect(row(en, metrics, "aComparison.metricIncidentRate")).toMatchObject({
105
+ before: "not measured",
106
+ after: "not measured",
107
+ delta: { value: null },
108
+ })
109
+ expect(JSON.stringify(metrics)).not.toContain('"0.0"')
110
+ })
111
+
112
+ it("renders 'nothing ended' as an unmeasured duration, never 0 ms (N78)", () => {
113
+ german()
114
+ const noneEnded = kpis({ avg_duration_sec: null, p95_duration_sec: null })
115
+ const metrics = buildComparisonMetrics(de, kpis(), noneEnded, {
116
+ ...delta,
117
+ avg_duration_delta_pct: null,
118
+ })
119
+
120
+ for (const labelKey of ["aComparison.metricAvgDuration", "aComparison.metricP95Duration"]) {
121
+ expect(row(de, metrics, labelKey)).toMatchObject({
122
+ after: "nicht gemessen",
123
+ delta: { value: null },
124
+ })
125
+ }
126
+ expect(JSON.stringify(metrics)).not.toContain("0 ms")
127
+ })
128
+
129
+ it("shows the element incident rate only when the comparison was scoped to an element", () => {
130
+ english()
131
+ expect(
132
+ row(
133
+ en,
134
+ buildComparisonMetrics(en, kpis(), kpis(), delta),
135
+ "aComparison.metricElementIncidentRate",
136
+ ),
137
+ ).toBeUndefined()
138
+
139
+ const scoped = kpis({ element_incident_count: 2, element_incident_rate_pct: 5 })
140
+ expect(
141
+ row(
142
+ en,
143
+ buildComparisonMetrics(
144
+ en,
145
+ scoped,
146
+ kpis({ element_incident_count: 0, element_incident_rate_pct: 0 }),
147
+ { ...delta, element_incident_rate_delta_pp: -5 },
148
+ ),
149
+ "aComparison.metricElementIncidentRate",
150
+ ),
151
+ ).toMatchObject({ before: "5.0", after: "0.0", delta: { value: -5, unit: "rate", rule: RATE } })
152
+ })
153
+ })
154
+
155
+ describe("deltaVerdict — U6: volume neutral, thresholds per metric, suppressed not reliable", () => {
156
+ it("never judges a volume: fewer starts on a test engine are no regression", () => {
157
+ for (const value of [-84.7, 0, 427.4]) {
158
+ expect(deltaVerdict(value, VOLUME, false)).toBeUndefined()
159
+ expect(deltaVerdict(value, VOLUME, true)).toBeUndefined()
160
+ }
161
+ })
162
+
163
+ it("judges a duration only from 5 % on, in either direction", () => {
164
+ expect(deltaVerdict(4.9, DURATION, false)).toBeUndefined()
165
+ expect(deltaVerdict(-4.9, DURATION, false)).toBeUndefined()
166
+ expect(deltaVerdict(5, DURATION, false)).toBe("worse")
167
+ expect(deltaVerdict(-5, DURATION, false)).toBe("better")
168
+ })
169
+
170
+ it("judges an incident rate only from 1 incident per 100 starts on", () => {
171
+ expect(deltaVerdict(0.2, RATE, false)).toBeUndefined()
172
+ expect(deltaVerdict(-0.94, RATE, false)).toBeUndefined()
173
+ expect(deltaVerdict(1, RATE, false)).toBe("worse")
174
+ expect(deltaVerdict(-6.5, RATE, false)).toBe("better")
175
+ })
176
+
177
+ it("judges the delta as the cell shows it, so one number never gets two readings", () => {
178
+ german()
179
+ // 4.96 shows as "+5 %": judged like 5. 4.94 shows as "+4,9 %": neutral.
180
+ expect(plain(formatDelta(4.96, "pct"))).toBe("+5 %")
181
+ expect(deltaVerdict(4.96, DURATION, false)).toBe("worse")
182
+ expect(deltaVerdict(-4.96, DURATION, false)).toBe("better")
183
+ expect(plain(formatDelta(4.94, "pct"))).toBe("+4,9 %")
184
+ expect(deltaVerdict(4.94, DURATION, false)).toBeUndefined()
185
+ // Half away from zero, like Intl: -0.95 shows as "-1" and is judged.
186
+ expect(formatDelta(-0.95, "rate")).toBe("-1")
187
+ expect(deltaVerdict(-0.95, RATE, false)).toBe("better")
188
+ expect(formatDelta(0.96, "rate")).toBe("+1")
189
+ expect(deltaVerdict(0.96, RATE, false)).toBe("worse")
190
+ // A change that rounds to zero shows no sign and no verdict.
191
+ expect(formatDelta(-0.04, "rate")).toBe("0")
192
+ expect(deltaVerdict(-0.04, RATE, false)).toBeUndefined()
193
+ })
194
+
195
+ it("follows the metric's direction: a rule where up is good flips the verdict", () => {
196
+ const upIsGood: DeltaRule = { kind: "quality", worseIfUp: false, minChange: 1 }
197
+ expect(deltaVerdict(3, upIsGood, false)).toBe("better")
198
+ expect(deltaVerdict(-3, upIsGood, false)).toBe("worse")
199
+ })
200
+
201
+ it("calls every quality delta of a suppressed comparison not reliable, below threshold too", () => {
202
+ expect(deltaVerdict(-100, DURATION, true)).toBe("notReliable")
203
+ expect(deltaVerdict(0.1, RATE, true)).toBe("notReliable")
204
+ })
205
+
206
+ it("has nothing to judge for a missing delta (a zero baseline)", () => {
207
+ expect(deltaVerdict(null, DURATION, false)).toBeUndefined()
208
+ expect(deltaVerdict(null, RATE, true)).toBeUndefined()
209
+ })
210
+ })
211
+
212
+ describe("formatDelta", () => {
213
+ it("signs relative changes as percent and rate changes in the rate's own unit", () => {
214
+ english()
215
+ expect(formatDelta(12.5, "pct")).toBe("+12.5%")
216
+ expect(formatDelta(-84.66, "pct")).toBe("-84.7%")
217
+ expect(formatDelta(0.24, "rate")).toBe("+0.2")
218
+ expect(formatDelta(0, "rate")).toBe("0")
219
+ expect(formatDelta(null, "pct")).toBe("—")
220
+ german()
221
+ expect(plain(formatDelta(12.5, "pct"))).toBe("+12,5 %")
222
+ expect(formatDelta(-2, "rate")).toBe("-2")
223
+ })
224
+ })
225
+
226
+ describe("deltaFacts", () => {
227
+ it("names starts per day and leaves a never-measured delta null — never a 0", () => {
228
+ expect(deltaFacts(delta, false)).toEqual({
229
+ startedPerDayDeltaPct: 10,
230
+ incidentRateDeltaPp: -2,
231
+ elementIncidentRateDeltaPp: null,
232
+ avgDurationDeltaPct: 5,
233
+ p95DurationDeltaPct: null,
234
+ suppressed: false,
235
+ })
236
+ })
237
+ })
@@ -0,0 +1,371 @@
1
+ import type { ReactNode } from "react"
2
+ import {
3
+ Alert,
4
+ AlertDescription,
5
+ Card,
6
+ CardContent,
7
+ Table,
8
+ TableBody,
9
+ TableCell,
10
+ TableHead,
11
+ TableHeader,
12
+ TableRow,
13
+ cn,
14
+ TONE_ICON,
15
+ } from "@miragon/mcp-toolkit-ui"
16
+ import { CircleAlert, TrendingDown, TrendingUp } from "lucide-react"
17
+ import {
18
+ Icon,
19
+ formatDuration,
20
+ formatNumber,
21
+ formatPercent,
22
+ } from "@miragon/pulse-widget-shell/widgets"
23
+ import type { CompareKpiDelta, CompareKpis } from "@miragon/pulse-analytics-client"
24
+ import { useT, type T } from "../messages/use-t.js"
25
+
26
+ /**
27
+ * The on-screen deltas as hand-off facts (#338) — null deltas (a zero
28
+ * baseline) are left out, like the em-dash on screen, never sent as 0.
29
+ */
30
+ export function deltaFacts(delta: CompareKpiDelta, suppressed: boolean) {
31
+ return {
32
+ // Starts compare PER DAY, so a clamped (shorter) window compares fairly.
33
+ startedPerDayDeltaPct: delta.started_per_day_delta_pct,
34
+ incidentRateDeltaPp: delta.incident_rate_delta_pp,
35
+ // Non-null only when the comparison was scoped to an element.
36
+ elementIncidentRateDeltaPp: delta.element_incident_rate_delta_pp,
37
+ avgDurationDeltaPct: delta.avg_duration_delta_pct,
38
+ p95DurationDeltaPct: delta.p95_duration_delta_pct,
39
+ suppressed,
40
+ }
41
+ }
42
+
43
+ /**
44
+ * How a metric's change is judged (U6). Only a QUALITY metric earns a verdict,
45
+ * and only from its own threshold on, so rounding jitter stays neutral:
46
+ *
47
+ * - `volume` (starts): never better or worse. Fewer starts on a test engine,
48
+ * or traffic moving to a new version, is no regression.
49
+ * - `quality`: "worse" or "better" once the change reaches `minChange`, in
50
+ * the delta's own unit (percent for durations, incidents per 100 starts
51
+ * for the rates).
52
+ */
53
+ export type DeltaRule =
54
+ { kind: "volume" } | { kind: "quality"; worseIfUp: boolean; minChange: number }
55
+
56
+ /** A duration changes noticeably from 5 % on (relative). */
57
+ export const DURATION_MIN_CHANGE_PCT = 5
58
+ /** An incident rate changes noticeably from 1 incident per 100 starts on (absolute). */
59
+ export const RATE_MIN_CHANGE = 1
60
+
61
+ export type DeltaVerdict = "worse" | "better" | "notReliable"
62
+
63
+ /**
64
+ * A delta as its cell shows it: one decimal, rounded half away from zero
65
+ * like `Intl.NumberFormat`. The verdict judges this number, so the same
66
+ * "+5 %" on screen is never neutral in one row and "schlechter" in another.
67
+ */
68
+ function shownDelta(value: number): number {
69
+ return Math.sign(value) * (Math.round(Math.abs(value) * 10) / 10)
70
+ }
71
+
72
+ /**
73
+ * The verdict on one delta, or `undefined` for none: a missing (null) delta,
74
+ * a volume, or a change below the metric's threshold. The threshold applies
75
+ * to the delta as shown ({@link shownDelta}). In a suppressed comparison (a
76
+ * side below `minBucketSize`) every quality delta is "not reliable": the
77
+ * screen must not judge what the model is told is noise.
78
+ */
79
+ export function deltaVerdict(
80
+ value: number | null,
81
+ rule: DeltaRule,
82
+ suppressed: boolean,
83
+ ): DeltaVerdict | undefined {
84
+ if (value === null || rule.kind === "volume") return undefined
85
+ if (suppressed) return "notReliable"
86
+ const shown = shownDelta(value)
87
+ if (Math.abs(shown) < rule.minChange) return undefined
88
+ const bad = rule.worseIfUp ? shown > 0 : shown < 0
89
+ return bad ? "worse" : "better"
90
+ }
91
+
92
+ /**
93
+ * `pct`: a relative change in percent (starts per day, durations);
94
+ * `rate`: an absolute change of incidents per 100 starts, the unit of the
95
+ * values themselves.
96
+ */
97
+ type DeltaUnit = "pct" | "rate"
98
+
99
+ /**
100
+ * A signed delta in the view's locale ("+12,5 %", "−0,4"), or an em-dash when
101
+ * null. It formats {@link shownDelta}, the number the verdict judges.
102
+ */
103
+ export function formatDelta(value: number | null, unit: DeltaUnit): string {
104
+ const shown = value === null ? null : shownDelta(value)
105
+ if (unit === "pct") return formatPercent(shown, { signed: true })
106
+ return formatNumber(shown, { signDisplay: "exceptZero", maximumFractionDigits: 1 })
107
+ }
108
+
109
+ /**
110
+ * The compare KPIs the table reads. Every rate and duration is nullable — a
111
+ * window in which nothing ended has no duration, one in which nothing started
112
+ * no rate — and a null renders "not measured", never "0,0" or "0 ms".
113
+ */
114
+ type ComparableKpis = Pick<CompareKpis, "instance_count"> & {
115
+ incident_rate_pct: number | null
116
+ /** Non-null exactly when the comparison was scoped to an element. */
117
+ element_incident_count: number | null
118
+ element_incident_rate_pct: number | null
119
+ avg_duration_sec: number | null
120
+ p95_duration_sec: number | null
121
+ }
122
+
123
+ /** Incidents per 100 starts, one decimal: "6,6" / "6.6" (no %: a rate may pass 100). */
124
+ const rateValue = (n: number | null) =>
125
+ n === null ? null : formatNumber(n, { minimumFractionDigits: 1, maximumFractionDigits: 1 })
126
+ const durationValue = (sec: number | null) => (sec === null ? null : formatDuration(sec * 1000))
127
+
128
+ /** A single metric row: label, the two compared values, and the RAW delta with its rule. */
129
+ export type ComparisonMetric = {
130
+ label: string
131
+ before: string
132
+ after: string
133
+ delta: { value: number | null; unit: DeltaUnit; rule: DeltaRule }
134
+ }
135
+
136
+ const VOLUME: DeltaRule = { kind: "volume" }
137
+ /** Incidents per 100 starts: up is worse, from 1 per 100 starts on (also the dashboard's trend). */
138
+ export const RATE_UP_IS_WORSE: DeltaRule = {
139
+ kind: "quality",
140
+ worseIfUp: true,
141
+ minChange: RATE_MIN_CHANGE,
142
+ }
143
+ const DURATION_UP_IS_WORSE: DeltaRule = {
144
+ kind: "quality",
145
+ worseIfUp: true,
146
+ minChange: DURATION_MIN_CHANGE_PCT,
147
+ }
148
+
149
+ /**
150
+ * The one metric table shared by the cluster / version / engine compare
151
+ * widgets — same `CompareKpis`/`CompareKpiDelta` core, so the rows are
152
+ * defined once. Deltas stay numeric here; formatting and the verdict happen
153
+ * at render time in {@link ComparisonCard}.
154
+ */
155
+ const COMPARE_METRICS: Array<{
156
+ labelKey: string
157
+ /** Formatted value, or null when the KPI is not measured for this comparison. */
158
+ value: (k: ComparableKpis) => string | null
159
+ delta: (d: CompareKpiDelta) => number | null
160
+ unit: DeltaUnit
161
+ rule: DeltaRule
162
+ /** Shown only when the comparison was scoped to an element. */
163
+ elementOnly?: boolean
164
+ }> = [
165
+ {
166
+ // The count per window; the delta compares starts PER DAY, so a clamped
167
+ // (shorter) window still compares fairly. A volume: no verdict.
168
+ labelKey: "aComparison.metricStarted",
169
+ value: (k) => formatNumber(k.instance_count),
170
+ delta: (d) => d.started_per_day_delta_pct,
171
+ unit: "pct",
172
+ rule: VOLUME,
173
+ },
174
+ {
175
+ labelKey: "aComparison.metricIncidentRate",
176
+ value: (k) => rateValue(k.incident_rate_pct),
177
+ delta: (d) => d.incident_rate_delta_pp,
178
+ unit: "rate",
179
+ rule: RATE_UP_IS_WORSE,
180
+ },
181
+ {
182
+ labelKey: "aComparison.metricElementIncidentRate",
183
+ value: (k) => rateValue(k.element_incident_rate_pct),
184
+ delta: (d) => d.element_incident_rate_delta_pp,
185
+ unit: "rate",
186
+ rule: RATE_UP_IS_WORSE,
187
+ elementOnly: true,
188
+ },
189
+ {
190
+ labelKey: "aComparison.metricAvgDuration",
191
+ value: (k) => durationValue(k.avg_duration_sec),
192
+ delta: (d) => d.avg_duration_delta_pct,
193
+ unit: "pct",
194
+ rule: DURATION_UP_IS_WORSE,
195
+ },
196
+ {
197
+ labelKey: "aComparison.metricP95Duration",
198
+ value: (k) => durationValue(k.p95_duration_sec),
199
+ delta: (d) => d.p95_duration_delta_pct,
200
+ unit: "pct",
201
+ rule: DURATION_UP_IS_WORSE,
202
+ },
203
+ ]
204
+
205
+ /** Resolve the shared metric rows for one baseline/other KPI pair. */
206
+ export function buildComparisonMetrics(
207
+ t: T,
208
+ before: ComparableKpis,
209
+ after: ComparableKpis,
210
+ delta: CompareKpiDelta,
211
+ ): ComparisonMetric[] {
212
+ const unavailable = t("aComparison.valueUnavailable")
213
+ const elementScoped =
214
+ before.element_incident_count !== null || after.element_incident_count !== null
215
+ return COMPARE_METRICS.filter((m) => !m.elementOnly || elementScoped).map((m) => ({
216
+ label: t(m.labelKey),
217
+ before: m.value(before) ?? unavailable,
218
+ after: m.value(after) ?? unavailable,
219
+ delta: { value: m.delta(delta), unit: m.unit, rule: m.rule },
220
+ }))
221
+ }
222
+
223
+ const VERDICT_KEY: Record<DeltaVerdict, string> = {
224
+ worse: "aComparison.deltaWorse",
225
+ better: "aComparison.deltaBetter",
226
+ notReliable: "aComparison.deltaNotReliable",
227
+ }
228
+
229
+ /**
230
+ * A single delta cell. The number stays neutral (CI: a state is an icon next
231
+ * to black text, never a coloured digit); a verdict shows as a direction
232
+ * icon in its tone plus the word ("schlechter", "besser"), so it never rests
233
+ * on colour alone. A volume or a change below the threshold shows the signed
234
+ * number only; a suppressed comparison mutes it with "nicht belastbar".
235
+ */
236
+ function DeltaCell({
237
+ delta,
238
+ suppressed,
239
+ t,
240
+ }: {
241
+ delta: ComparisonMetric["delta"]
242
+ suppressed: boolean
243
+ t: T
244
+ }) {
245
+ const verdict = deltaVerdict(delta.value, delta.rule, suppressed)
246
+ const word = verdict ? t(VERDICT_KEY[verdict]) : undefined
247
+ const judged = verdict === "worse" || verdict === "better"
248
+ return (
249
+ <TableCell data-verdict={verdict ?? "none"}>
250
+ <span
251
+ className={cn(
252
+ "inline-flex items-center gap-1.5 tabular-nums",
253
+ verdict === "notReliable" && "text-muted-foreground",
254
+ )}
255
+ >
256
+ {judged && (
257
+ <Icon
258
+ icon={(delta.value ?? 0) > 0 ? TrendingUp : TrendingDown}
259
+ dense
260
+ className={TONE_ICON[verdict === "worse" ? "danger" : "success"]}
261
+ />
262
+ )}
263
+ {formatDelta(delta.value, delta.unit)}
264
+ {word && <span className="text-muted-foreground text-small">{word}</span>}
265
+ </span>
266
+ </TableCell>
267
+ )
268
+ }
269
+
270
+ /** Neutral "no data" / "incomplete" treatment — destructive is reserved for errors. */
271
+ export function ComparisonEmptyState({ children }: { children: ReactNode }) {
272
+ return (
273
+ <Alert>
274
+ <AlertDescription>{children}</AlertDescription>
275
+ </Alert>
276
+ )
277
+ }
278
+
279
+ /**
280
+ * The "too little data" chip of a suppressed comparison: a data-quality note,
281
+ * not an error, so it stays neutral (outline + icon) like the deltas it
282
+ * explains. A plain outline chip, not the toolkit `Badge`: that one forces
283
+ * its icons to 12 px, and the icon keeps the kit's 16 px like every other
284
+ * icon in the view (CI §11, `scanIconSizes`).
285
+ */
286
+ export function SuppressedBadge({ children }: { children: ReactNode }) {
287
+ return (
288
+ <span
289
+ className="text-foreground text-small inline-flex w-fit shrink-0 items-center gap-1 rounded-sm border px-2 py-0.5 font-medium whitespace-nowrap"
290
+ data-suppressed=""
291
+ >
292
+ <Icon icon={CircleAlert} dense />
293
+ {children}
294
+ </span>
295
+ )
296
+ }
297
+
298
+ /**
299
+ * Shared comparison card used by the cluster / version / engine compare widgets.
300
+ *
301
+ * Callers resolve their own KPI buckets (`buildComparisonMetrics`) and pass in
302
+ * the metric rows plus the heading, badges, the reference frame (`meta`, a
303
+ * `ViewMeta`) and the two comparison column labels.
304
+ */
305
+ export function ComparisonCard({
306
+ title,
307
+ badges,
308
+ meta,
309
+ beforeLabel,
310
+ afterLabel,
311
+ tableLabel,
312
+ metrics,
313
+ suppressed,
314
+ actions,
315
+ note,
316
+ }: {
317
+ title: string
318
+ badges: ReactNode
319
+ /** The quiet line under the title: period, engines, as-of time. */
320
+ meta?: ReactNode
321
+ beforeLabel: ReactNode
322
+ afterLabel: ReactNode
323
+ tableLabel: string
324
+ metrics: ComparisonMetric[]
325
+ /** A side is below `minBucketSize`: every quality delta reads "not reliable". */
326
+ suppressed: boolean
327
+ /** Optional header action slot (e.g. an AI affordance), right-aligned. */
328
+ actions?: ReactNode
329
+ /** Optional caveat under the table, e.g. why a metric reads "not measured". */
330
+ note?: ReactNode
331
+ }) {
332
+ const t = useT()
333
+ return (
334
+ <Card>
335
+ <CardContent>
336
+ <div className="flex flex-wrap items-center gap-3">
337
+ <h2 className="text-foreground text-section">{title}</h2>
338
+ {badges}
339
+ {actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
340
+ </div>
341
+ {meta && <div className="mt-1">{meta}</div>}
342
+
343
+ <Table className="mt-4" aria-label={tableLabel}>
344
+ <TableHeader>
345
+ <TableRow>
346
+ <TableHead scope="col">{t("aComparison.metricColumnHeader")}</TableHead>
347
+ <TableHead scope="col">{beforeLabel}</TableHead>
348
+ <TableHead scope="col">{afterLabel}</TableHead>
349
+ <TableHead scope="col">{t("aComparison.deltaColumnHeader")}</TableHead>
350
+ </TableRow>
351
+ </TableHeader>
352
+ <TableBody>
353
+ {metrics.map((m) => (
354
+ <TableRow key={m.label}>
355
+ <TableCell>{m.label}</TableCell>
356
+ <TableCell className="tabular-nums">{m.before}</TableCell>
357
+ <TableCell className="tabular-nums">{m.after}</TableCell>
358
+ <DeltaCell delta={m.delta} suppressed={suppressed} t={t} />
359
+ </TableRow>
360
+ ))}
361
+ </TableBody>
362
+ </Table>
363
+ {note && (
364
+ <Alert className="mt-4">
365
+ <AlertDescription>{note}</AlertDescription>
366
+ </Alert>
367
+ )}
368
+ </CardContent>
369
+ </Card>
370
+ )
371
+ }