@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,251 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ describeEngineLandscape,
4
+ describeErrorPatterns,
5
+ describeFailureRates,
6
+ describeFailureSummary,
7
+ describeVersionCompare,
8
+ } from "./model-descriptions.js"
9
+ import type {
10
+ EngineLandscapeEngine,
11
+ EngineLandscapeResult,
12
+ FailureDashboardData,
13
+ VersionCompareKpi,
14
+ VersionCompareResult,
15
+ } from "@miragon/pulse-analytics-client"
16
+
17
+ // The dashboard, comparison and heatmap descriptions: model-descriptions.dashboard.test.ts.
18
+
19
+ const engine = (
20
+ engineId: string,
21
+ over: Partial<EngineLandscapeEngine> = {},
22
+ ): EngineLandscapeEngine => ({
23
+ engineId,
24
+ reporting: true,
25
+ runningInstances: 0,
26
+ openIncidents: 0,
27
+ failedJobs: 0,
28
+ executableJobs: 0,
29
+ suspendedJobs: 0,
30
+ jobsDueFuture: 0,
31
+ openExternalTasks: 0,
32
+ deployedDefinitionKeys: 0,
33
+ exclusiveDefinitionKeys: 0,
34
+ ...over,
35
+ })
36
+
37
+ const landscape = (over: Partial<EngineLandscapeResult> = {}): EngineLandscapeResult => ({
38
+ engines: [
39
+ engine("prod-a", { runningInstances: 12, executableJobs: 3, deployedDefinitionKeys: 2 }),
40
+ engine("prod-b", { runningInstances: 4, executableJobs: 90, deployedDefinitionKeys: 1 }),
41
+ ],
42
+ processes: [],
43
+ sharedProcessKeys: ["order"],
44
+ totals: {
45
+ engineCount: 2,
46
+ reportingEngineCount: 2,
47
+ processKeyCount: 3,
48
+ sharedProcessKeyCount: 1,
49
+ runningInstances: 16,
50
+ openIncidents: 2,
51
+ failedJobs: 0,
52
+ },
53
+ ...over,
54
+ })
55
+
56
+ describe("describeEngineLandscape", () => {
57
+ it("names the busiest engine, the largest backlog and the comparable process", () => {
58
+ const text = describeEngineLandscape(landscape(), {})
59
+
60
+ expect(text).toContain('busiestEngine="prod-a", busiestRunningInstances=12')
61
+ expect(text).toContain('largestBacklogEngine="prod-b", largestBacklogExecutableJobs=90')
62
+ expect(text).toContain('sharedProcessKeys=["order"]')
63
+ expect(text).toContain("Tools: analytics_engine_compare, analytics_engine_health")
64
+ })
65
+
66
+ it("tells the model why per-engine rates are absent", () => {
67
+ // The whole point of the view: without this line a model would happily
68
+ // divide the counts into per-engine rates and compare them again.
69
+ expect(describeEngineLandscape(landscape(), {})).toContain("different process mixes")
70
+ })
71
+
72
+ it("states that no valid engine comparison exists when nothing is shared", () => {
73
+ const text = describeEngineLandscape(landscape({ sharedProcessKeys: [] }), {})
74
+
75
+ expect(text).toContain("No definition runs on more than one engine")
76
+ // Without a shared definition there is nothing to compare: no compare tool.
77
+ expect(text).not.toContain("analytics_engine_compare")
78
+ expect(text).toContain("Tools: analytics_engine_health")
79
+ })
80
+
81
+ it("calls out engines that report no metrics at all", () => {
82
+ const text = describeEngineLandscape(
83
+ landscape({
84
+ engines: [
85
+ engine("prod-a", { runningInstances: 1 }),
86
+ engine("prod-c", { reporting: false }),
87
+ ],
88
+ }),
89
+ {},
90
+ )
91
+
92
+ expect(text).toContain('enginesReportingNoMetrics=["prod-c"]')
93
+ })
94
+ })
95
+
96
+ const versionKpi = (version: number, over: Partial<VersionCompareKpi> = {}): VersionCompareKpi => ({
97
+ version,
98
+ bucket: version === 1 ? "versionA" : "versionB",
99
+ instance_count: 100,
100
+ completed_count: 90,
101
+ incident_count: 2,
102
+ incident_rate_pct: 2,
103
+ element_incident_count: null,
104
+ element_incident_rate_pct: null,
105
+ avg_duration_sec: 10,
106
+ p95_duration_sec: 20,
107
+ ...over,
108
+ })
109
+
110
+ const versionCompare = (
111
+ kpis: VersionCompareKpi[],
112
+ engines: string[] | null = ["prod-a"],
113
+ ): VersionCompareResult => ({
114
+ engines,
115
+ processDefinitionKey: "order",
116
+ versionA: 1,
117
+ versionB: 2,
118
+ windowDays: 14,
119
+ minBucketSize: 10,
120
+ suppressed: false,
121
+ kpis,
122
+ delta: {
123
+ started_per_day_delta_pct: 0,
124
+ incident_rate_delta_pp: 0,
125
+ element_incident_rate_delta_pp: null,
126
+ avg_duration_delta_pct: 25,
127
+ p95_duration_delta_pct: null,
128
+ },
129
+ })
130
+
131
+ describe("describeVersionCompare", () => {
132
+ it("names the measured deltas, the incident rate per version included", () => {
133
+ const text = describeVersionCompare(versionCompare([versionKpi(1), versionKpi(2)]), {})
134
+
135
+ expect(text).toContain("avgDurationDeltaPct=25")
136
+ expect(text).toContain("incidentRateDeltaPp=0")
137
+ expect(text).toContain('largestDelta="avgDurationDeltaPct"')
138
+ // An unmeasured delta is left out — never sent as 0.
139
+ expect(text).not.toContain("p95DurationDeltaPct")
140
+ expect(text).not.toMatch(/failure/i)
141
+ })
142
+
143
+ // The tool takes no element scope (#336): the comparison is process-wide,
144
+ // on the engines it read.
145
+ it("re-runs the process-wide comparison on the engines on screen", () => {
146
+ const text = describeVersionCompare(versionCompare([versionKpi(1), versionKpi(2)]), {})
147
+ expect(text).toContain(
148
+ 'Ids: engine="prod-a", processDefinitionKey="order", versionA=1, versionB=2, windowDays=14\n',
149
+ )
150
+ expect(text).toContain("every figure covers the whole process")
151
+ expect(text).not.toContain("activityId")
152
+ })
153
+
154
+ it("names every engine of a fleet comparison (K33)", () => {
155
+ const text = describeVersionCompare(
156
+ versionCompare([versionKpi(1), versionKpi(2)], ["prod-a", "prod-b"]),
157
+ {},
158
+ )
159
+ expect(text).toContain('Ids: engine=["prod-a","prod-b"]')
160
+ expect(text).toContain("(aggregated)")
161
+ })
162
+ })
163
+
164
+ const failures = (engines: string[] | null): FailureDashboardData => ({
165
+ engines,
166
+ totalIncidents: 6,
167
+ uniqueErrorPatterns: 1,
168
+ mostAffectedProcess: "order",
169
+ errorPatterns: [{ incidentType: "failedJob", processDefinitionKey: "order", incidentCount: 6 }],
170
+ processBreakdown: [
171
+ {
172
+ processDefinitionKey: "order",
173
+ runningNow: 50,
174
+ deadJobs: 3,
175
+ openIncidents: 6,
176
+ incidentRatePct: 12,
177
+ },
178
+ ],
179
+ })
180
+
181
+ describe("describeFailureRates", () => {
182
+ it("states live open incidents per running instance and routes the regression check (#327)", () => {
183
+ const text = describeFailureRates(failures(["prod-a"]), {})
184
+
185
+ expect(text).toContain('Ids: engine="prod-a"\n')
186
+ expect(text).toContain(
187
+ 'highestRateProcessDefinitionKey="order", highestOpenIncidentsNow=6, highestRunningNow=50, highestIncidentRatePct=12, highestDeadJobs=3',
188
+ )
189
+ expect(text).toContain("Tools: analytics_compare_execution_periods, analytics_cluster_compare")
190
+ // Its incident rates are null per version — a wasted call for this question.
191
+ expect(text).not.toContain("analytics_version_compare")
192
+ // Incidents, not failed instances (N85).
193
+ expect(text).not.toMatch(/failed|failure/i)
194
+ })
195
+
196
+ it("leaves an unmeasured rate out instead of sending it as 0", () => {
197
+ const idle = failures(["prod-a"])
198
+ idle.processBreakdown = [{ ...idle.processBreakdown[0], runningNow: 0, incidentRatePct: null }]
199
+ expect(describeFailureRates(idle, {})).not.toContain("highestIncidentRatePct")
200
+ })
201
+ })
202
+
203
+ describe("failure-dashboard scope (N83/N116)", () => {
204
+ it("names the engines from the data, not from cell props — a self-fetch keeps its label", () => {
205
+ for (const describeCell of [
206
+ describeFailureSummary,
207
+ describeErrorPatterns,
208
+ describeFailureRates,
209
+ ]) {
210
+ const fleet = describeCell(failures(["prod-a", "prod-b"]), {})
211
+ expect(fleet).toContain('Ids: engine=["prod-a","prod-b"]')
212
+ expect(fleet).toContain("(aggregated)")
213
+ // Props claiming another engine never override what the data covers.
214
+ const scoped = describeCell(failures(["prod-a"]), { engine: "prod-b" })
215
+ expect(scoped).toContain('Ids: engine="prod-a"')
216
+ expect(scoped).not.toContain("prod-b")
217
+ // An unscoped library result names no engine — never a placeholder.
218
+ expect(describeCell(failures(null), {})).not.toContain("engine=")
219
+ }
220
+ })
221
+
222
+ it("presents only fields the open-incident metric fills (N117)", () => {
223
+ const text = describeErrorPatterns(failures(["prod-a"]), {})
224
+ expect(text).toContain(
225
+ 'On screen: groups=1, largestGroupProcessDefinitionKey="order", largestGroupIncidentType="failedJob", largestGroupIncidents=6',
226
+ )
227
+ expect(text).toContain("no message, activity or timestamps")
228
+ })
229
+
230
+ // The view spans every process: the largest group's key is a fact, never
231
+ // the scope the model passes on; the list is capped at 50, the totals not.
232
+ it("scopes the view by engine only and states the exact group count of a capped list", () => {
233
+ const capped: FailureDashboardData = {
234
+ ...failures(["prod-a", "prod-b"]),
235
+ uniqueErrorPatterns: 73,
236
+ errorPatterns: Array.from({ length: 50 }, (_, i) => ({
237
+ incidentType: "failedJob",
238
+ processDefinitionKey: i === 0 ? "order" : `process_${i}`,
239
+ incidentCount: 100 - i,
240
+ })),
241
+ }
242
+ const text = describeErrorPatterns(capped, {})
243
+ expect(text).toContain('Ids: engine=["prod-a","prod-b"]\n')
244
+ expect(text).not.toMatch(/[,\s]processDefinitionKey=/)
245
+ expect(text).toContain(
246
+ 'On screen: groups=73, listedGroups=50, largestGroupProcessDefinitionKey="order", largestGroupIncidentType="failedJob", largestGroupIncidents=100',
247
+ )
248
+ // An uncapped list is the whole set — no listed count beside it.
249
+ expect(describeErrorPatterns(failures(["prod-a"]), {})).not.toContain("listedGroups")
250
+ })
251
+ })
@@ -0,0 +1,428 @@
1
+ import type {
2
+ AnalyticsDashboardData,
3
+ CompareKpiDelta,
4
+ FailureDashboardData,
5
+ } from "@miragon/pulse-analytics-client"
6
+ import { modelContextText, type ModelContextSpec } from "@miragon/pulse-widget-shell/widgets"
7
+ import type { DescribeForModel } from "@miragon/pulse-widget-shell/ui"
8
+ import { ANALYTICS_ONLY_SURFACE, engineIdsOf, isAggregate } from "./hand-off.js"
9
+ import type { ClusterCompareData } from "./cluster-compare.js"
10
+ import type { VersionCompareData } from "./version-compare.js"
11
+ import type { EngineCompareData } from "./engine-compare.js"
12
+ import type { EngineLandscapeData } from "./engine-landscape.js"
13
+ import type { AnalyticsSettingsViewData } from "./settings-section.js"
14
+ import type { AnalyticsBpmnHeatmapData } from "./bpmn-heatmap.js"
15
+ import { deltaFacts } from "./comparison-shared.js"
16
+
17
+ /**
18
+ * Model-context descriptions for every analytics widget, attached centrally via
19
+ * `adaptDataWidget(..., describe)` in `widgets/index.ts`. Each one is built by
20
+ * `modelContextText` (#338, CLAUDE.md invariant 6) — a static summary of the
21
+ * view, the ids the follow-up tools take, the headline numbers as facts, and
22
+ * the natural follow-up tools — never a hand-written template: a value is
23
+ * inlined only when id-shaped, and a tool is only ever named in `tools`.
24
+ *
25
+ * The scope comes from the DATA, never from the cell props (#336): every
26
+ * analytics result echoes what it covers (process key, period, `engines`), so
27
+ * a widget that self-fetched, was saved or composed by render-view describes
28
+ * exactly the numbers on screen — including which engines a fleet aggregate
29
+ * adds up. A figure that was not measured is null and left out, never a 0.
30
+ *
31
+ * A pure description cannot query the camunda7 surface, so these name
32
+ * analytics tools only (`ANALYTICS_ONLY_SURFACE`); the Ask-AI hand-offs (live
33
+ * surface) add the camunda7 ones where the deployment has them.
34
+ */
35
+
36
+ /** Said whenever a figure adds up several engines (the deliberate fleet aggregate). */
37
+ const AGGREGATED = " The figures add up every engine listed (aggregated)."
38
+
39
+ /**
40
+ * Said for an incident rate over several processes: a total over their mix,
41
+ * so the screen judges no change of it (`incidentRateTrend`).
42
+ */
43
+ const MIXED_RATE =
44
+ " The incident rate totals several processes, so its change against the previous period reflects their mix and is not judged; compare per process."
45
+
46
+ /** How many definitions an unscoped dashboard covers (none when scoped to one). */
47
+ function definitionCount(data: AnalyticsDashboardData) {
48
+ return data.processDefinitionKey ? undefined : data.definitionBreakdown.length
49
+ }
50
+
51
+ /**
52
+ * The execution summary: flows within the period (`…InWindow` — incidents,
53
+ * not failed instances) kept apart from the live gauges (`…Now`), which do
54
+ * not depend on the period (#336).
55
+ */
56
+ export const describeExecutionSummary: DescribeForModel<AnalyticsDashboardData> = (data) =>
57
+ modelContextText({
58
+ summary:
59
+ "The operator is viewing the execution summary of the process analytics: flows within the period (incidents, not failed instances) apart from the live state right now, which does not depend on the period." +
60
+ (isAggregate(data.engines) ? AGGREGATED : ""),
61
+ ids: {
62
+ engine: engineIdsOf(data.engines),
63
+ processDefinitionKey: data.processDefinitionKey ?? undefined,
64
+ period: data.period,
65
+ },
66
+ facts: {
67
+ processDefinitions: definitionCount(data),
68
+ startedInWindow: data.totalCount,
69
+ completedInWindow: data.completedCount,
70
+ incidentsCreatedInWindow: data.incidentsCreated,
71
+ incidentsResolvedInWindow: data.incidentsResolved,
72
+ runningNow: data.runningNow,
73
+ openIncidentsNow: data.openIncidentsNow,
74
+ },
75
+ tools: ["analytics_analyze_process_performance", "analytics_find_failed_instances"],
76
+ surface: ANALYTICS_ONLY_SURFACE,
77
+ })
78
+
79
+ /** The performance KPIs: durations of the instances that ENDED in the period (null when none did). */
80
+ export const describeExecutionPerformance: DescribeForModel<AnalyticsDashboardData> = (data) =>
81
+ modelContextText({
82
+ summary:
83
+ "The operator is viewing the performance KPIs of the process analytics: durations of the instances that ended in the period, and incidents per 100 started instances." +
84
+ (data.avgDurationMs === null
85
+ ? " No instance ended in the period, so there are no durations."
86
+ : "") +
87
+ (data.processDefinitionKey ? "" : MIXED_RATE) +
88
+ (isAggregate(data.engines) ? AGGREGATED : ""),
89
+ ids: {
90
+ engine: engineIdsOf(data.engines),
91
+ processDefinitionKey: data.processDefinitionKey ?? undefined,
92
+ period: data.period,
93
+ },
94
+ facts: {
95
+ processDefinitions: definitionCount(data),
96
+ avgDurationMs: data.avgDurationMs,
97
+ medianDurationMs: data.medianDurationMs,
98
+ p95DurationMs: data.p95DurationMs,
99
+ incidentRatePct: data.incidentRatePct,
100
+ // The reference the rate is judged against on screen (one process
101
+ // only): the period before.
102
+ previousPeriodIncidentRatePct: data.previous?.incidentRatePct,
103
+ previousPeriodComparisonNotReliable:
104
+ (data.processDefinitionKey && data.previous?.suppressed) || undefined,
105
+ },
106
+ tools: ["analytics_element_bottleneck"],
107
+ surface: ANALYTICS_ONLY_SURFACE,
108
+ })
109
+
110
+ export const describeDefinitionBreakdown: DescribeForModel<AnalyticsDashboardData> = (data) => {
111
+ const top = [...data.definitionBreakdown].sort((a, b) => b.totalInstances - a.totalInstances)[0]
112
+ return modelContextText({
113
+ summary:
114
+ "The operator is viewing the per-definition breakdown of the process analytics; " +
115
+ "the dashboard scopes to one process by its processDefinitionKey." +
116
+ (isAggregate(data.engines) ? AGGREGATED : ""),
117
+ ids: {
118
+ engine: engineIdsOf(data.engines),
119
+ processDefinitionKey: data.processDefinitionKey ?? undefined,
120
+ period: data.period,
121
+ },
122
+ facts: {
123
+ processDefinitions: data.definitionBreakdown.length,
124
+ busiestProcessDefinitionKey: top?.processDefinitionKey,
125
+ busiestStartedInWindow: top?.totalInstances,
126
+ busiestIncidentsCreatedInWindow: top?.incidentsCreated,
127
+ busiestRunningNow: top?.runningNow,
128
+ },
129
+ tools: ["analytics_show_dashboard"],
130
+ surface: ANALYTICS_ONLY_SURFACE,
131
+ })
132
+ }
133
+
134
+ /**
135
+ * The bottleneck table: one row per (process, activity) — BPMN ids are only
136
+ * unique within one model, so the top row names its process too.
137
+ */
138
+ export const describeActivityBottlenecks: DescribeForModel<AnalyticsDashboardData> = (data) => {
139
+ const top = [...data.activityBreakdown].sort((a, b) => b.totalTimeMs - a.totalTimeMs)[0]
140
+ return modelContextText({
141
+ summary:
142
+ "The operator is viewing the activity-bottleneck table of the process analytics: one row per (process, activity)." +
143
+ (isAggregate(data.engines) ? AGGREGATED : ""),
144
+ ids: {
145
+ engine: engineIdsOf(data.engines),
146
+ processDefinitionKey: data.processDefinitionKey ?? undefined,
147
+ period: data.period,
148
+ },
149
+ facts: {
150
+ rows: data.activityBreakdown.length,
151
+ // The list is the top of the breakdown by total time: how many there were in all.
152
+ activitiesInAll:
153
+ data.activityCount > data.activityBreakdown.length ? data.activityCount : undefined,
154
+ topProcessDefinitionKey: top?.processDefinitionKey,
155
+ topActivityId: top?.activityId,
156
+ topExecutions: top?.executionCount,
157
+ topTotalTimeMs: top?.totalTimeMs,
158
+ topP95DurationMs: top?.p95DurationMs,
159
+ },
160
+ tools: ["analytics_element_bottleneck"],
161
+ surface: ANALYTICS_ONLY_SURFACE,
162
+ })
163
+ }
164
+
165
+ /**
166
+ * The failure summary (point-in-time). A pure description cannot query the
167
+ * camunda7 surface, so it names analytics tools only; the Ask-AI hand-off
168
+ * (live surface) adds the camunda7 ones where the deployment has them.
169
+ */
170
+ export const describeFailureSummary: DescribeForModel<FailureDashboardData> = (data) =>
171
+ modelContextText({
172
+ summary:
173
+ "The operator is viewing the failure dashboard: the incidents open right now (point-in-time, no period), grouped by incident type and process." +
174
+ (isAggregate(data.engines) ? AGGREGATED : ""),
175
+ ids: { engine: engineIdsOf(data.engines) },
176
+ facts: {
177
+ openIncidents: data.totalIncidents,
178
+ incidentGroups: data.uniqueErrorPatterns,
179
+ mostAffectedProcess: data.mostAffectedProcess,
180
+ },
181
+ tools: ["analytics_find_failed_instances"],
182
+ surface: ANALYTICS_ONLY_SURFACE,
183
+ })
184
+
185
+ /**
186
+ * The open-incident groups. The gauge carries only incident type, process and
187
+ * count — no message, activity or timestamps. A custom incident type is any
188
+ * string: unless id-shaped it is quoted as untrusted data (#338). The view
189
+ * spans every process, so the largest group's key is a fact, never a scope;
190
+ * the group count is the exact one, and a capped list says how much it shows.
191
+ */
192
+ export const describeErrorPatterns: DescribeForModel<FailureDashboardData> = (data) => {
193
+ const top = [...data.errorPatterns].sort((a, b) => b.incidentCount - a.incidentCount)[0]
194
+ const listed = data.errorPatterns.length
195
+ return modelContextText({
196
+ summary:
197
+ "The operator is viewing the incidents open right now (point-in-time), grouped by incident type and process; the metric carries no message, activity or timestamps." +
198
+ (isAggregate(data.engines) ? AGGREGATED : ""),
199
+ ids: { engine: engineIdsOf(data.engines) },
200
+ // A value that is not id-shaped moves into the untrusted fence by itself.
201
+ facts: {
202
+ groups: data.uniqueErrorPatterns,
203
+ listedGroups: listed < data.uniqueErrorPatterns ? listed : undefined,
204
+ largestGroupProcessDefinitionKey: top?.processDefinitionKey,
205
+ largestGroupIncidentType: top?.incidentType,
206
+ largestGroupIncidents: top?.incidentCount,
207
+ },
208
+ tools: ["analytics_find_failed_instances"],
209
+ surface: ANALYTICS_ONLY_SURFACE,
210
+ })
211
+ }
212
+
213
+ /**
214
+ * Open incidents by process (point-in-time live gauges): open incidents per
215
+ * 100 running instances and dead jobs — incidents, not failed instances
216
+ * (#336). The regression check goes to the tools that measure incident rates
217
+ * over time — not analytics_version_compare: it cannot split incidents by
218
+ * version, so its incident rates are null (#327).
219
+ */
220
+ export const describeFailureRates: DescribeForModel<FailureDashboardData> = (data) => {
221
+ const top = [...data.processBreakdown].sort(
222
+ (a, b) => (b.incidentRatePct ?? -1) - (a.incidentRatePct ?? -1),
223
+ )[0]
224
+ return modelContextText({
225
+ summary:
226
+ "The operator is viewing the incidents open right now by process (point-in-time): open " +
227
+ "incidents per 100 running instances and dead jobs. A regression shows period over " +
228
+ "period or around a deployment." +
229
+ (isAggregate(data.engines) ? AGGREGATED : ""),
230
+ ids: { engine: engineIdsOf(data.engines) },
231
+ facts: {
232
+ processes: data.processBreakdown.length,
233
+ highestRateProcessDefinitionKey: top?.processDefinitionKey,
234
+ highestOpenIncidentsNow: top?.openIncidents,
235
+ highestRunningNow: top?.runningNow,
236
+ highestIncidentRatePct: top?.incidentRatePct,
237
+ highestDeadJobs: top?.deadJobs,
238
+ },
239
+ tools: ["analytics_compare_execution_periods", "analytics_cluster_compare"],
240
+ surface: ANALYTICS_ONLY_SURFACE,
241
+ })
242
+ }
243
+
244
+ /**
245
+ * The deltas on screen plus the single largest one — the number a user asks
246
+ * about first. The candidates are the {@link deltaFacts} keys: a new
247
+ * `CompareKpiDelta` field must be added there.
248
+ */
249
+ function comparisonFacts(delta: CompareKpiDelta, suppressed: boolean) {
250
+ const facts = deltaFacts(delta, suppressed)
251
+ const moved = Object.entries(facts).filter(
252
+ (entry): entry is [string, number] => typeof entry[1] === "number" && entry[1] !== 0,
253
+ )
254
+ const largest = moved.reduce<[string, number] | null>(
255
+ (max, entry) => (max === null || Math.abs(entry[1]) > Math.abs(max[1]) ? entry : max),
256
+ null,
257
+ )
258
+ return { ...facts, largestDelta: largest?.[0] ?? "none" }
259
+ }
260
+
261
+ const SUPPRESSED = " The sample is below minBucketSize (suppressed): treat the deltas as noise."
262
+
263
+ /**
264
+ * Before/after a deployment. The ids re-run the comparison as asked (the
265
+ * requested whole-day windows); the windows actually measured — cut short at
266
+ * now or the retention — are facts (#336).
267
+ */
268
+ export const describeClusterCompare: DescribeForModel<ClusterCompareData> = (data) =>
269
+ modelContextText({
270
+ summary:
271
+ "The operator is comparing KPIs before vs after a deployment (cluster-wide unless a " +
272
+ "processDefinitionKey scopes it); durations cover only the instances that ended in a window." +
273
+ (data.partial
274
+ ? " A window was cut short at now or the retention (partial): starts compare per day."
275
+ : "") +
276
+ (data.suppressed ? SUPPRESSED : "") +
277
+ (isAggregate(data.engines) ? AGGREGATED : ""),
278
+ ids: {
279
+ engine: engineIdsOf(data.engines),
280
+ deploymentTimestamp: data.deploymentTimestamp,
281
+ windowBeforeDays: data.requestedWindowDays.before,
282
+ windowAfterDays: data.requestedWindowDays.after,
283
+ processDefinitionKey: data.processDefinitionKey,
284
+ activityId: data.activityId,
285
+ },
286
+ facts: {
287
+ measuredBeforeDays: data.windowDays.before,
288
+ measuredAfterDays: data.windowDays.after,
289
+ ...comparisonFacts(data.delta, data.suppressed),
290
+ },
291
+ tools: ["analytics_cluster_compare", "analytics_element_bottleneck"],
292
+ surface: ANALYTICS_ONLY_SURFACE,
293
+ })
294
+
295
+ /** Version compare: process-wide (the tool takes no element scope, #336). */
296
+ export const describeVersionCompare: DescribeForModel<VersionCompareData> = (data) =>
297
+ modelContextText({
298
+ summary:
299
+ "The operator is comparing two versions of one process (versionA is the baseline); every " +
300
+ "figure covers the whole process." +
301
+ (data.suppressed ? SUPPRESSED : "") +
302
+ (isAggregate(data.engines) ? AGGREGATED : ""),
303
+ ids: {
304
+ engine: engineIdsOf(data.engines),
305
+ processDefinitionKey: data.processDefinitionKey,
306
+ versionA: data.versionA,
307
+ versionB: data.versionB,
308
+ windowDays: data.windowDays,
309
+ },
310
+ facts: comparisonFacts(data.delta, data.suppressed),
311
+ tools: ["analytics_version_compare", "analytics_element_bottleneck"],
312
+ surface: ANALYTICS_ONLY_SURFACE,
313
+ })
314
+
315
+ export const describeEngineCompare: DescribeForModel<EngineCompareData> = (data) =>
316
+ modelContextText({
317
+ summary:
318
+ "The operator is comparing one process on two engines (engineA is the baseline). The " +
319
+ "process is held fixed, so a delta is attributable to the engine rather than to a " +
320
+ "different workload." +
321
+ (data.suppressed ? SUPPRESSED : ""),
322
+ ids: {
323
+ processDefinitionKey: data.processDefinitionKey,
324
+ engineA: data.engineA,
325
+ engineB: data.engineB,
326
+ windowDays: data.windowDays,
327
+ activityId: data.activityId,
328
+ },
329
+ facts: comparisonFacts(data.delta, data.suppressed),
330
+ tools: ["analytics_engine_compare", "analytics_engine_health", "analytics_engine_landscape"],
331
+ surface: ANALYTICS_ONLY_SURFACE,
332
+ })
333
+
334
+ export const describeEngineLandscape: DescribeForModel<EngineLandscapeData> = (data) => {
335
+ const { totals } = data
336
+ const silent = data.engines.filter((e) => !e.reporting).map((e) => e.engineId)
337
+ // Ranked among the engines that report: a silent engine's counts are unknown (null).
338
+ const measured = data.engines.flatMap((e) =>
339
+ e.reporting
340
+ ? [{ ...e, running: e.runningInstances ?? 0, executable: e.executableJobs ?? 0 }]
341
+ : [],
342
+ )
343
+ const busiest = [...measured].sort((a, b) => b.running - a.running)[0]
344
+ const backlog = [...measured].sort((a, b) => b.executable - a.executable)[0]
345
+ const shared = data.sharedProcessKeys.length > 0
346
+ return modelContextText({
347
+ summary:
348
+ "The operator is viewing the cross-engine landscape: absolute counts and the " +
349
+ "process-independent job backlog on purpose — engines run different process mixes, so " +
350
+ "per-engine failure rates or durations would describe the mix, not the engine." +
351
+ (shared
352
+ ? " A KPI comparison is only sound for a definition deployed on several engines " +
353
+ "(sharedProcessKeys), compared by its processDefinitionKey."
354
+ : " No definition runs on more than one engine, so there is no valid engine-vs-engine " +
355
+ "KPI comparison here."),
356
+ facts: {
357
+ engines: totals.engineCount,
358
+ processDefinitions: totals.processKeyCount,
359
+ runningInstances: totals.runningInstances,
360
+ openIncidents: totals.openIncidents,
361
+ enginesReportingNoMetrics: silent.length > 0 ? silent : undefined,
362
+ busiestEngine: busiest?.engineId,
363
+ busiestRunningInstances: busiest?.running,
364
+ largestBacklogEngine: backlog && backlog.executable > 0 ? backlog.engineId : undefined,
365
+ largestBacklogExecutableJobs:
366
+ backlog && backlog.executable > 0 ? backlog.executable : undefined,
367
+ sharedProcessKeys: shared ? data.sharedProcessKeys : undefined,
368
+ },
369
+ tools: ["analytics_engine_compare", "analytics_engine_health"].filter(
370
+ (tool) => shared || tool !== "analytics_engine_compare",
371
+ ),
372
+ surface: ANALYTICS_ONLY_SURFACE,
373
+ })
374
+ }
375
+
376
+ function maxEntry(values: Record<string, number>): [string, number] | null {
377
+ let best: [string, number] | null = null
378
+ for (const [key, value] of Object.entries(values)) {
379
+ if (!best || value > best[1]) best = [key, value]
380
+ }
381
+ return best
382
+ }
383
+
384
+ /**
385
+ * The settings section. Its save tool is named only while the section can
386
+ * save (`canSave`: the toolset registers the write AND the caller has an
387
+ * identity to save under) — the model is never told about a write that fails.
388
+ * Rendered in-component (the section self-fetches), hence a spec, not a text.
389
+ */
390
+ export function describeAnalyticsSettings(data: AnalyticsSettingsViewData): ModelContextSpec {
391
+ return {
392
+ summary:
393
+ "The operator is viewing the analytics settings: the defaults every analytics call uses when it omits period or minBucketSize.",
394
+ facts: {
395
+ defaultPeriod: data.settings.defaultPeriod,
396
+ minBucketSize: data.settings.minBucketSize,
397
+ editable: data.canSave,
398
+ },
399
+ tools: ["analytics_save_settings"],
400
+ surface: { has: (tool) => tool === "analytics_save_settings" && data.canSave },
401
+ }
402
+ }
403
+
404
+ /** The BPMN heatmap — over the engines its heat adds up (the `engines` echo, #336). */
405
+ export const describeBpmnHeatmap: DescribeForModel<AnalyticsBpmnHeatmapData> = (data) => {
406
+ const hottest = maxEntry(data.frequency)
407
+ const slowest = maxEntry(data.durationSec)
408
+ return modelContextText({
409
+ summary:
410
+ "The operator is viewing the BPMN heatmap of one process (the frequency ↔ duration toggle " +
411
+ "is client-side)." +
412
+ (isAggregate(data.engines) ? AGGREGATED : ""),
413
+ ids: {
414
+ engine: engineIdsOf(data.engines),
415
+ processDefinitionKey: data.processDefinitionKey,
416
+ period: data.period,
417
+ },
418
+ facts: {
419
+ elementsWithHeat: Object.keys(data.frequency).length,
420
+ hottestActivityId: hottest?.[0],
421
+ hottestExecutions: hottest?.[1],
422
+ slowestActivityId: slowest?.[0],
423
+ slowestAvgSec: slowest ? Math.round(slowest[1] * 10) / 10 : undefined,
424
+ },
425
+ tools: ["analytics_element_bottleneck"],
426
+ surface: ANALYTICS_ONLY_SURFACE,
427
+ })
428
+ }