@miragon/pulse-analytics-connector 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +87 -2
- package/dist/bpmn-name-lookup.d.ts +72 -0
- package/dist/bpmn-name-lookup.d.ts.map +1 -0
- package/dist/bpmn-name-lookup.js +123 -0
- package/dist/bpmn-name-lookup.js.map +1 -0
- package/dist/bpmn-name-lookup.test.d.ts +2 -0
- package/dist/bpmn-name-lookup.test.d.ts.map +1 -0
- package/dist/bpmn-name-lookup.test.js +287 -0
- package/dist/bpmn-name-lookup.test.js.map +1 -0
- package/dist/bpmn-names.d.ts +38 -0
- package/dist/bpmn-names.d.ts.map +1 -0
- package/dist/bpmn-names.js +22 -0
- package/dist/bpmn-names.js.map +1 -0
- package/dist/brand-gates.test.d.ts +2 -0
- package/dist/brand-gates.test.d.ts.map +1 -0
- package/dist/brand-gates.test.js +58 -0
- package/dist/brand-gates.test.js.map +1 -0
- package/dist/builder-texts.d.ts +4 -0
- package/dist/builder-texts.d.ts.map +1 -0
- package/dist/builder-texts.js +14 -0
- package/dist/builder-texts.js.map +1 -0
- package/dist/builder-texts.test.d.ts +2 -0
- package/dist/builder-texts.test.d.ts.map +1 -0
- package/dist/builder-texts.test.js +32 -0
- package/dist/builder-texts.test.js.map +1 -0
- package/dist/definition.d.ts +3 -0
- package/dist/definition.d.ts.map +1 -0
- package/dist/definition.js +151 -0
- package/dist/definition.js.map +1 -0
- package/dist/engine-ids.d.ts +42 -0
- package/dist/engine-ids.d.ts.map +1 -0
- package/dist/engine-ids.js +50 -0
- package/dist/engine-ids.js.map +1 -0
- package/dist/engine-scope.test.d.ts +2 -0
- package/dist/engine-scope.test.d.ts.map +1 -0
- package/dist/engine-scope.test.js +260 -0
- package/dist/engine-scope.test.js.map +1 -0
- package/dist/heatmap-data.d.ts +15 -0
- package/dist/heatmap-data.d.ts.map +1 -0
- package/dist/heatmap-data.js +2 -0
- package/dist/heatmap-data.js.map +1 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/instructions.d.ts +8 -0
- package/dist/instructions.d.ts.map +1 -0
- package/dist/instructions.js +33 -0
- package/dist/instructions.js.map +1 -0
- package/dist/messages/de.ask-ai.d.ts +9 -0
- package/dist/messages/de.ask-ai.d.ts.map +1 -0
- package/dist/messages/de.ask-ai.js +18 -0
- package/dist/messages/de.ask-ai.js.map +1 -0
- package/dist/messages/de.builder.d.ts +12 -0
- package/dist/messages/de.builder.d.ts.map +1 -0
- package/dist/messages/de.builder.js +53 -0
- package/dist/messages/de.builder.js.map +1 -0
- package/dist/messages/de.server.d.ts +8 -0
- package/dist/messages/de.server.d.ts.map +1 -0
- package/dist/messages/de.server.js +26 -0
- package/dist/messages/de.server.js.map +1 -0
- package/dist/messages/de.sweep.d.ts +10 -0
- package/dist/messages/de.sweep.d.ts.map +1 -0
- package/dist/messages/de.sweep.js +203 -0
- package/dist/messages/de.sweep.js.map +1 -0
- package/dist/messages/en.ask-ai.d.ts +25 -0
- package/dist/messages/en.ask-ai.d.ts.map +1 -0
- package/dist/messages/en.ask-ai.js +24 -0
- package/dist/messages/en.ask-ai.js.map +1 -0
- package/dist/messages/en.builder.d.ts +12 -0
- package/dist/messages/en.builder.d.ts.map +1 -0
- package/dist/messages/en.builder.js +53 -0
- package/dist/messages/en.builder.js.map +1 -0
- package/dist/messages/en.server.d.ts +8 -0
- package/dist/messages/en.server.d.ts.map +1 -0
- package/dist/messages/en.server.js +26 -0
- package/dist/messages/en.server.js.map +1 -0
- package/dist/messages/en.sweep.d.ts +11 -0
- package/dist/messages/en.sweep.d.ts.map +1 -0
- package/dist/messages/en.sweep.js +204 -0
- package/dist/messages/en.sweep.js.map +1 -0
- package/dist/messages/glossary.d.ts +26 -0
- package/dist/messages/glossary.d.ts.map +1 -0
- package/dist/messages/glossary.js +37 -0
- package/dist/messages/glossary.js.map +1 -0
- package/dist/messages/glossary.test.d.ts +2 -0
- package/dist/messages/glossary.test.d.ts.map +1 -0
- package/dist/messages/glossary.test.js +109 -0
- package/dist/messages/glossary.test.js.map +1 -0
- package/dist/messages/index.d.ts +12 -0
- package/dist/messages/index.d.ts.map +1 -0
- package/dist/messages/index.js +23 -0
- package/dist/messages/index.js.map +1 -0
- package/dist/messages/plural.d.ts +10 -0
- package/dist/messages/plural.d.ts.map +1 -0
- package/dist/messages/plural.js +17 -0
- package/dist/messages/plural.js.map +1 -0
- package/dist/messages/use-t.d.ts +10 -0
- package/dist/messages/use-t.d.ts.map +1 -0
- package/dist/messages/use-t.js +10 -0
- package/dist/messages/use-t.js.map +1 -0
- package/dist/module.d.ts +48 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +174 -0
- package/dist/module.js.map +1 -0
- package/dist/module.test.d.ts +2 -0
- package/dist/module.test.d.ts.map +1 -0
- package/dist/module.test.js +230 -0
- package/dist/module.test.js.map +1 -0
- package/dist/named-views.d.ts +27 -0
- package/dist/named-views.d.ts.map +1 -0
- package/dist/named-views.js +73 -0
- package/dist/named-views.js.map +1 -0
- package/dist/named-views.test.d.ts +2 -0
- package/dist/named-views.test.d.ts.map +1 -0
- package/dist/named-views.test.js +110 -0
- package/dist/named-views.test.js.map +1 -0
- package/dist/plugin.d.ts +44 -0
- package/dist/plugin.d.ts.map +1 -0
- package/dist/plugin.js +52 -0
- package/dist/plugin.js.map +1 -0
- package/dist/prometheus-wiring.test.d.ts +2 -0
- package/dist/prometheus-wiring.test.d.ts.map +1 -0
- package/dist/prometheus-wiring.test.js +142 -0
- package/dist/prometheus-wiring.test.js.map +1 -0
- package/dist/server-locale.d.ts +48 -0
- package/dist/server-locale.d.ts.map +1 -0
- package/dist/server-locale.js +33 -0
- package/dist/server-locale.js.map +1 -0
- package/dist/settings-tools.d.ts +23 -0
- package/dist/settings-tools.d.ts.map +1 -0
- package/dist/settings-tools.js +100 -0
- package/dist/settings-tools.js.map +1 -0
- package/dist/settings.d.ts +77 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +99 -0
- package/dist/settings.js.map +1 -0
- package/dist/settings.test.d.ts +2 -0
- package/dist/settings.test.d.ts.map +1 -0
- package/dist/settings.test.js +299 -0
- package/dist/settings.test.js.map +1 -0
- package/dist/steps/app-config.d.ts +30 -0
- package/dist/steps/app-config.d.ts.map +1 -0
- package/dist/steps/app-config.js +46 -0
- package/dist/steps/app-config.js.map +1 -0
- package/dist/steps/dashboard.d.ts +12 -0
- package/dist/steps/dashboard.d.ts.map +1 -0
- package/dist/steps/dashboard.js +58 -0
- package/dist/steps/dashboard.js.map +1 -0
- package/dist/steps/failure-dashboard.d.ts +10 -0
- package/dist/steps/failure-dashboard.d.ts.map +1 -0
- package/dist/steps/failure-dashboard.js +32 -0
- package/dist/steps/failure-dashboard.js.map +1 -0
- package/dist/steps/index.d.ts +3 -0
- package/dist/steps/index.d.ts.map +1 -0
- package/dist/steps/index.js +3 -0
- package/dist/steps/index.js.map +1 -0
- package/dist/steps/steps.test.d.ts +2 -0
- package/dist/steps/steps.test.d.ts.map +1 -0
- package/dist/steps/steps.test.js +153 -0
- package/dist/steps/steps.test.js.map +1 -0
- package/dist/tool-names.d.ts +13 -0
- package/dist/tool-names.d.ts.map +1 -0
- package/dist/tool-names.js +27 -0
- package/dist/tool-names.js.map +1 -0
- package/dist/tools/cluster-compare.d.ts +8 -0
- package/dist/tools/cluster-compare.d.ts.map +1 -0
- package/dist/tools/cluster-compare.js +17 -0
- package/dist/tools/cluster-compare.js.map +1 -0
- package/dist/tools/element.d.ts +8 -0
- package/dist/tools/element.d.ts.map +1 -0
- package/dist/tools/element.js +25 -0
- package/dist/tools/element.js.map +1 -0
- package/dist/tools/engine-compare.d.ts +8 -0
- package/dist/tools/engine-compare.d.ts.map +1 -0
- package/dist/tools/engine-compare.js +18 -0
- package/dist/tools/engine-compare.js.map +1 -0
- package/dist/tools/engine-compare.test.d.ts +2 -0
- package/dist/tools/engine-compare.test.d.ts.map +1 -0
- package/dist/tools/engine-compare.test.js +166 -0
- package/dist/tools/engine-compare.test.js.map +1 -0
- package/dist/tools/engine-landscape.d.ts +7 -0
- package/dist/tools/engine-landscape.d.ts.map +1 -0
- package/dist/tools/engine-landscape.js +13 -0
- package/dist/tools/engine-landscape.js.map +1 -0
- package/dist/tools/engine-landscape.test.d.ts +2 -0
- package/dist/tools/engine-landscape.test.d.ts.map +1 -0
- package/dist/tools/engine-landscape.test.js +110 -0
- package/dist/tools/engine-landscape.test.js.map +1 -0
- package/dist/tools/failures.d.ts +7 -0
- package/dist/tools/failures.d.ts.map +1 -0
- package/dist/tools/failures.js +13 -0
- package/dist/tools/failures.js.map +1 -0
- package/dist/tools/health.d.ts +7 -0
- package/dist/tools/health.d.ts.map +1 -0
- package/dist/tools/health.js +23 -0
- package/dist/tools/health.js.map +1 -0
- package/dist/tools/health.test.d.ts +2 -0
- package/dist/tools/health.test.d.ts.map +1 -0
- package/dist/tools/health.test.js +119 -0
- package/dist/tools/health.test.js.map +1 -0
- package/dist/tools/index.d.ts +18 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +35 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/performance.d.ts +8 -0
- package/dist/tools/performance.d.ts.map +1 -0
- package/dist/tools/performance.js +25 -0
- package/dist/tools/performance.js.map +1 -0
- package/dist/tools/performance.test.d.ts +2 -0
- package/dist/tools/performance.test.d.ts.map +1 -0
- package/dist/tools/performance.test.js +176 -0
- package/dist/tools/performance.test.js.map +1 -0
- package/dist/tools/version-compare.d.ts +8 -0
- package/dist/tools/version-compare.d.ts.map +1 -0
- package/dist/tools/version-compare.js +17 -0
- package/dist/tools/version-compare.js.map +1 -0
- package/dist/tools/version-compare.test.d.ts +2 -0
- package/dist/tools/version-compare.test.d.ts.map +1 -0
- package/dist/tools/version-compare.test.js +82 -0
- package/dist/tools/version-compare.test.js.map +1 -0
- package/dist/toolsets.d.ts +17 -0
- package/dist/toolsets.d.ts.map +1 -0
- package/dist/toolsets.js +47 -0
- package/dist/toolsets.js.map +1 -0
- package/dist/toolsets.test.d.ts +2 -0
- package/dist/toolsets.test.d.ts.map +1 -0
- package/dist/toolsets.test.js +91 -0
- package/dist/toolsets.test.js.map +1 -0
- package/dist/view-title.test.d.ts +2 -0
- package/dist/view-title.test.d.ts.map +1 -0
- package/dist/view-title.test.js +91 -0
- package/dist/view-title.test.js.map +1 -0
- package/dist/widget-tools/comparisons.d.ts +13 -0
- package/dist/widget-tools/comparisons.d.ts.map +1 -0
- package/dist/widget-tools/comparisons.js +177 -0
- package/dist/widget-tools/comparisons.js.map +1 -0
- package/dist/widget-tools/comparisons.test.d.ts +2 -0
- package/dist/widget-tools/comparisons.test.d.ts.map +1 -0
- package/dist/widget-tools/comparisons.test.js +123 -0
- package/dist/widget-tools/comparisons.test.js.map +1 -0
- package/dist/widget-tools/shared.d.ts +36 -0
- package/dist/widget-tools/shared.d.ts.map +1 -0
- package/dist/widget-tools/shared.js +39 -0
- package/dist/widget-tools/shared.js.map +1 -0
- package/dist/widget-tools.d.ts +25 -0
- package/dist/widget-tools.d.ts.map +1 -0
- package/dist/widget-tools.js +269 -0
- package/dist/widget-tools.js.map +1 -0
- package/dist/widget-tools.test.d.ts +2 -0
- package/dist/widget-tools.test.d.ts.map +1 -0
- package/dist/widget-tools.test.js +272 -0
- package/dist/widget-tools.test.js.map +1 -0
- package/package.json +91 -3
- package/src/bpmn-name-lookup.test.ts +333 -0
- package/src/bpmn-name-lookup.ts +197 -0
- package/src/bpmn-names.ts +51 -0
- package/src/brand-gates.test.ts +69 -0
- package/src/builder-texts.test.ts +40 -0
- package/src/builder-texts.ts +15 -0
- package/src/definition.ts +181 -0
- package/src/engine-ids.ts +85 -0
- package/src/engine-scope.test.ts +324 -0
- package/src/heatmap-data.ts +14 -0
- package/src/index.ts +4 -0
- package/src/instructions.ts +33 -0
- package/src/messages/GLOSSARY.md +88 -0
- package/src/messages/de.ask-ai.ts +28 -0
- package/src/messages/de.builder.ts +77 -0
- package/src/messages/de.server.ts +39 -0
- package/src/messages/de.sweep.ts +244 -0
- package/src/messages/en.ask-ai.ts +34 -0
- package/src/messages/en.builder.ts +76 -0
- package/src/messages/en.server.ts +39 -0
- package/src/messages/en.sweep.ts +240 -0
- package/src/messages/glossary.test.ts +122 -0
- package/src/messages/glossary.ts +62 -0
- package/src/messages/index.ts +24 -0
- package/src/messages/plural.ts +18 -0
- package/src/messages/use-t.ts +12 -0
- package/src/module.test.ts +319 -0
- package/src/module.ts +211 -0
- package/src/named-views.test.ts +141 -0
- package/src/named-views.ts +97 -0
- package/src/plugin.ts +94 -0
- package/src/prometheus-wiring.test.ts +174 -0
- package/src/server-locale.ts +59 -0
- package/src/settings-tools.ts +170 -0
- package/src/settings.test.ts +402 -0
- package/src/settings.ts +114 -0
- package/src/steps/app-config.ts +75 -0
- package/src/steps/dashboard.ts +73 -0
- package/src/steps/failure-dashboard.ts +38 -0
- package/src/steps/index.ts +2 -0
- package/src/steps/steps.test.ts +216 -0
- package/src/tool-names.ts +26 -0
- package/src/tools/cluster-compare.ts +28 -0
- package/src/tools/element.ts +35 -0
- package/src/tools/engine-compare.test.ts +210 -0
- package/src/tools/engine-compare.ts +30 -0
- package/src/tools/engine-landscape.test.ts +138 -0
- package/src/tools/engine-landscape.ts +21 -0
- package/src/tools/failures.ts +19 -0
- package/src/tools/health.test.ts +156 -0
- package/src/tools/health.ts +29 -0
- package/src/tools/index.ts +44 -0
- package/src/tools/performance.test.ts +210 -0
- package/src/tools/performance.ts +38 -0
- package/src/tools/version-compare.test.ts +97 -0
- package/src/tools/version-compare.ts +28 -0
- package/src/toolsets.test.ts +107 -0
- package/src/toolsets.ts +56 -0
- package/src/view-title.test.ts +123 -0
- package/src/widget-tools/comparisons.test.ts +170 -0
- package/src/widget-tools/comparisons.ts +229 -0
- package/src/widget-tools/shared.ts +67 -0
- package/src/widget-tools.test.ts +306 -0
- package/src/widget-tools.ts +375 -0
- package/src/widgets/analytics-dashboard/activity-bottleneck-table.tsx +179 -0
- package/src/widgets/analytics-dashboard/definition-breakdown.tsx +108 -0
- package/src/widgets/analytics-dashboard/execution-performance-kpi.tsx +94 -0
- package/src/widgets/analytics-dashboard/execution-summary-kpi.tsx +122 -0
- package/src/widgets/analytics-dashboard/lib.ts +43 -0
- package/src/widgets/bpmn-heatmap.test.tsx +75 -0
- package/src/widgets/bpmn-heatmap.tsx +127 -0
- package/src/widgets/catalogue-sync.test.ts +16 -0
- package/src/widgets/cluster-compare.tsx +148 -0
- package/src/widgets/comparison-shared.test.ts +237 -0
- package/src/widgets/comparison-shared.tsx +371 -0
- package/src/widgets/comparison-widgets.test.tsx +307 -0
- package/src/widgets/dashboard-widgets.test.tsx +397 -0
- package/src/widgets/engine-compare.tsx +87 -0
- package/src/widgets/engine-landscape.tsx +352 -0
- package/src/widgets/failure-dashboard/error-patterns-table.tsx +122 -0
- package/src/widgets/failure-dashboard/failure-rate-table.test.ts +80 -0
- package/src/widgets/failure-dashboard/failure-rate-table.tsx +170 -0
- package/src/widgets/failure-dashboard/lib.ts +32 -0
- package/src/widgets/failure-dashboard/summary-kpi.tsx +122 -0
- package/src/widgets/failure-landscape-widgets.test.tsx +393 -0
- package/src/widgets/figure.tsx +79 -0
- package/src/widgets/hand-off.test.ts +126 -0
- package/src/widgets/hand-off.ts +102 -0
- package/src/widgets/index.ts +124 -0
- package/src/widgets/model-descriptions.dashboard.test.ts +280 -0
- package/src/widgets/model-descriptions.test.ts +251 -0
- package/src/widgets/model-descriptions.ts +428 -0
- package/src/widgets/names.test.ts +32 -0
- package/src/widgets/names.ts +39 -0
- package/src/widgets/query-gate.test.tsx +57 -0
- package/src/widgets/query-gate.tsx +50 -0
- package/src/widgets/settings-section.test.tsx +174 -0
- package/src/widgets/settings-section.tsx +244 -0
- package/src/widgets/version-compare.test.ts +68 -0
- package/src/widgets/version-compare.tsx +96 -0
- package/src/widgets/view-meta.ts +18 -0
- package/src/widgets/widget-render.test-support.tsx +103 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { z } from "zod"
|
|
3
|
+
import type { PrometheusClient, PromSample } from "@miragon/pulse-analytics-client"
|
|
4
|
+
import type { RegisteredToolMeta, ToolConfig } from "@miragon/mcp-toolkit-core/tools"
|
|
5
|
+
import { createEngineScope } from "../engine-ids.js"
|
|
6
|
+
import { registerEngineLandscapeTools } from "./engine-landscape.js"
|
|
7
|
+
|
|
8
|
+
/** The engines this server is configured for — prod-c reports nothing. */
|
|
9
|
+
const scope = createEngineScope(["prod-a", "prod-b", "prod-c"])
|
|
10
|
+
const register = (r: never) => registerEngineLandscapeTools(r, scope)
|
|
11
|
+
|
|
12
|
+
type Config = ToolConfig<PrometheusClient>
|
|
13
|
+
|
|
14
|
+
/** Registrar stand-in that captures each tool's full config instead of an MCPServer. */
|
|
15
|
+
function captureConfigs(registerModule: (register: never) => void): Map<string, Config> {
|
|
16
|
+
const configs = new Map<string, Config>()
|
|
17
|
+
const register = Object.assign(
|
|
18
|
+
(config: Config) => {
|
|
19
|
+
configs.set(config.name, config)
|
|
20
|
+
},
|
|
21
|
+
{ getRegisteredTools: (): RegisteredToolMeta[] => [] },
|
|
22
|
+
)
|
|
23
|
+
registerModule(register as never)
|
|
24
|
+
return configs
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function captureHandlers(registerModule: (register: never) => void) {
|
|
28
|
+
const configs = captureConfigs(registerModule)
|
|
29
|
+
return new Map([...configs].map(([name, c]) => [name, c.handler]))
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Records every PromQL string and answers with a two-engine landscape. */
|
|
33
|
+
function recordingClient(): { client: PrometheusClient; queries: string[] } {
|
|
34
|
+
const queries: string[] = []
|
|
35
|
+
const cell = (engine_id: string, process_definition_key: string, value: number): PromSample => ({
|
|
36
|
+
metric: { engine_id, process_definition_key },
|
|
37
|
+
value,
|
|
38
|
+
})
|
|
39
|
+
return {
|
|
40
|
+
client: {
|
|
41
|
+
instant: (query: string) => {
|
|
42
|
+
queries.push(query)
|
|
43
|
+
if (query.includes("process_definitions_deployed")) {
|
|
44
|
+
return Promise.resolve([cell("prod-a", "order", 1), cell("prod-b", "onboarding", 1)])
|
|
45
|
+
}
|
|
46
|
+
if (query.includes("process_instances_running")) {
|
|
47
|
+
return Promise.resolve([cell("prod-a", "order", 5)])
|
|
48
|
+
}
|
|
49
|
+
return Promise.resolve([])
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
queries,
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
describe("analytics_engine_landscape", () => {
|
|
57
|
+
it("builds the inventory matrix and issues one node-folded query per metric", async () => {
|
|
58
|
+
const handlers = captureHandlers(register)
|
|
59
|
+
const { client, queries } = recordingClient()
|
|
60
|
+
|
|
61
|
+
const result = (await handlers.get("analytics_engine_landscape")!(client, {})) as {
|
|
62
|
+
engines: Array<{ engineId: string; runningInstances: number }>
|
|
63
|
+
sharedProcessKeys: string[]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Engine omitted = the configured fleet: the silent prod-c stays visible.
|
|
67
|
+
expect(result.engines.map((e) => e.engineId)).toEqual(["prod-a", "prod-b", "prod-c"])
|
|
68
|
+
expect(result.engines[0].runningInstances).toBe(5)
|
|
69
|
+
// Each engine runs its own definition — nothing to compare like-for-like.
|
|
70
|
+
expect(result.sharedProcessKeys).toEqual([])
|
|
71
|
+
const sel = '{engine_id=~"prod-a|prod-b|prod-c"}'
|
|
72
|
+
// Every gauge folded over its own labels first: a clustered engine's
|
|
73
|
+
// nodes report the same engine-wide rows.
|
|
74
|
+
const byKey = (gauge: string, labels = "process_definition_key, engine_id") =>
|
|
75
|
+
`sum by (engine_id, process_definition_key)(max by (${labels})(${gauge}${sel}))`
|
|
76
|
+
expect(queries).toEqual([
|
|
77
|
+
byKey("camunda_process_definitions_deployed"),
|
|
78
|
+
byKey("camunda_process_instances_running"),
|
|
79
|
+
byKey("camunda_incidents_open", "process_definition_key, incident_type, engine_id"),
|
|
80
|
+
byKey("camunda_jobs_failed"),
|
|
81
|
+
`max by (engine_id)(camunda_jobs_executable${sel})`,
|
|
82
|
+
`max by (engine_id)(camunda_jobs_suspended${sel})`,
|
|
83
|
+
`max by (engine_id)(camunda_jobs_due_future${sel})`,
|
|
84
|
+
`max by (engine_id)(camunda_external_tasks_open${sel})`,
|
|
85
|
+
])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("applies the engine filter and keeps a non-reporting engine visible", async () => {
|
|
89
|
+
const handlers = captureHandlers(register)
|
|
90
|
+
const { client, queries } = recordingClient()
|
|
91
|
+
|
|
92
|
+
const result = (await handlers.get("analytics_engine_landscape")!(client, {
|
|
93
|
+
engine: ["prod-a", "prod-c"],
|
|
94
|
+
})) as { engines: Array<{ engineId: string; reporting: boolean }> }
|
|
95
|
+
|
|
96
|
+
expect(queries.every((q) => q.includes('{engine_id=~"prod-a|prod-c"}'))).toBe(true)
|
|
97
|
+
expect(result.engines.find((e) => e.engineId === "prod-c")).toMatchObject({ reporting: false })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("never lists another tenant's engine from a shared Prometheus (N138)", async () => {
|
|
101
|
+
const handlers = captureHandlers(register)
|
|
102
|
+
const { client, queries } = recordingClient()
|
|
103
|
+
|
|
104
|
+
await expect(
|
|
105
|
+
handlers.get("analytics_engine_landscape")!(client, { engine: ["prod-a", "tenant-x"] }),
|
|
106
|
+
).rejects.toThrow(/Unknown engine "tenant-x"/)
|
|
107
|
+
expect(queries).toEqual([])
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe("analytics_engine_landscape registration", () => {
|
|
112
|
+
const config = captureConfigs(register).get("analytics_engine_landscape")!
|
|
113
|
+
|
|
114
|
+
it("registers under the analytics category as a read-only external read", () => {
|
|
115
|
+
expect(config.category).toBe("analytics")
|
|
116
|
+
expect(config.annotations).toEqual({
|
|
117
|
+
readOnlyHint: true,
|
|
118
|
+
idempotentHint: true,
|
|
119
|
+
openWorldHint: true,
|
|
120
|
+
})
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("tells the model that counts — not per-engine rates — are the point", () => {
|
|
124
|
+
// Without this the model would divide the counts back into per-engine
|
|
125
|
+
// rates and compare them, which is exactly the confound the view exists
|
|
126
|
+
// to avoid.
|
|
127
|
+
expect(config.description).toContain("counts, not rates")
|
|
128
|
+
expect(config.description).toContain("process mixes")
|
|
129
|
+
expect(config.description).toContain("sharedProcessKeys")
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("accepts an engine list or nothing at all", () => {
|
|
133
|
+
const schema = z.object(config.inputSchema)
|
|
134
|
+
expect(schema.safeParse({}).success).toBe(true)
|
|
135
|
+
expect(schema.safeParse({ engine: ["prod-a", "prod-b"] }).success).toBe(true)
|
|
136
|
+
expect(schema.safeParse({ engine: "prod-a" }).success).toBe(true)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { PrometheusClient } from "@miragon/pulse-analytics-client"
|
|
2
|
+
import { schemas, queries } from "@miragon/pulse-analytics-client"
|
|
3
|
+
import type { createToolRegistrar } from "@miragon/mcp-toolkit-core/tools"
|
|
4
|
+
import { withEngineScope, type AnalyticsEngineScope } from "../engine-ids.js"
|
|
5
|
+
|
|
6
|
+
type Register = ReturnType<typeof createToolRegistrar<PrometheusClient>>
|
|
7
|
+
|
|
8
|
+
export function registerEngineLandscapeTools(
|
|
9
|
+
register: Register,
|
|
10
|
+
engineScope: AnalyticsEngineScope,
|
|
11
|
+
) {
|
|
12
|
+
register({
|
|
13
|
+
name: "analytics_engine_landscape",
|
|
14
|
+
category: "analytics",
|
|
15
|
+
description:
|
|
16
|
+
"Cross-engine overview of the process landscape: which process definitions run on which engine (the inventory matrix), the absolute load per engine (running instances, open incidents, failed jobs), and the engine-owned job backlog (executable/suspended/future-due jobs, open external tasks). Reports counts, not rates, on purpose — engines host different process mixes, so a per-engine failure rate or average duration measures the mix rather than the engine. The backlog gauges carry no process label and therefore DO compare across engines. Returns `sharedProcessKeys`: the definitions deployed on more than one engine, the only sound inputs for analytics_engine_compare. Covers the server's configured engines (omitted engine = all of them); an engine reporting no metrics at all comes back with `reporting: false`.",
|
|
17
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
18
|
+
inputSchema: schemas.engineLandscapeInput.shape,
|
|
19
|
+
handler: async (ch, args) => queries.engineLandscape(ch, withEngineScope(engineScope, args)),
|
|
20
|
+
})
|
|
21
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { PrometheusClient } from "@miragon/pulse-analytics-client"
|
|
2
|
+
import { schemas, queries } from "@miragon/pulse-analytics-client"
|
|
3
|
+
import type { createToolRegistrar } from "@miragon/mcp-toolkit-core/tools"
|
|
4
|
+
import { withEngineScope, type AnalyticsEngineScope } from "../engine-ids.js"
|
|
5
|
+
|
|
6
|
+
type Register = ReturnType<typeof createToolRegistrar<PrometheusClient>>
|
|
7
|
+
|
|
8
|
+
export function registerFailureTools(register: Register, engineScope: AnalyticsEngineScope) {
|
|
9
|
+
register({
|
|
10
|
+
name: "analytics_find_failed_instances",
|
|
11
|
+
category: "analytics",
|
|
12
|
+
description:
|
|
13
|
+
"List currently-open incident patterns from process metrics, grouped by incident type and process definition (point-in-time — what is failing right now; no message, activity or timestamps). Use for counts across engines; for the actual failed instances (ids, messages) use camunda7_list_incidents, for past ones camunda7_query_historic_incidents.",
|
|
14
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
15
|
+
inputSchema: schemas.findFailedInstancesInput.shape,
|
|
16
|
+
handler: async (ch, args) =>
|
|
17
|
+
queries.findFailedInstances(ch, withEngineScope(engineScope, args)),
|
|
18
|
+
})
|
|
19
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
ENGINE_HEALTH_STATUS_RULE,
|
|
4
|
+
type EngineHealthResult,
|
|
5
|
+
type PrometheusClient,
|
|
6
|
+
type PromSample,
|
|
7
|
+
} from "@miragon/pulse-analytics-client"
|
|
8
|
+
import type { RegisteredToolMeta, ToolConfig } from "@miragon/mcp-toolkit-core/tools"
|
|
9
|
+
import { createEngineScope } from "../engine-ids.js"
|
|
10
|
+
import { registerHealthTools } from "./health.js"
|
|
11
|
+
|
|
12
|
+
type Config = ToolConfig<PrometheusClient>
|
|
13
|
+
|
|
14
|
+
/** The fleet this server is configured for. */
|
|
15
|
+
const scope = createEngineScope(["prod-a", "prod-b"])
|
|
16
|
+
|
|
17
|
+
/** Registrar stand-in that captures the tool's config instead of an MCPServer. */
|
|
18
|
+
function captureConfig(): Config {
|
|
19
|
+
const configs: Config[] = []
|
|
20
|
+
const register = Object.assign((config: Config) => configs.push(config), {
|
|
21
|
+
getRegisteredTools: (): RegisteredToolMeta[] => [],
|
|
22
|
+
})
|
|
23
|
+
registerHealthTools(register as never, scope)
|
|
24
|
+
return configs[0]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** PrometheusClient that records every instant PromQL string and answers via `answer`. */
|
|
28
|
+
function recordingClient(answer: (query: string) => PromSample[] = () => []) {
|
|
29
|
+
const queries: string[] = []
|
|
30
|
+
const client: PrometheusClient = {
|
|
31
|
+
instant: (query: string) => {
|
|
32
|
+
queries.push(query)
|
|
33
|
+
return Promise.resolve(answer(query))
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
return { client, queries }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const run = (client: PrometheusClient, args: Record<string, unknown>) =>
|
|
40
|
+
captureConfig().handler(client, args, {} as never) as Promise<EngineHealthResult>
|
|
41
|
+
|
|
42
|
+
/** Prometheus where only `prod-a` reports its engine-state gauges (when the selector admits it). */
|
|
43
|
+
const onlyProdAReports = (query: string): PromSample[] =>
|
|
44
|
+
query.includes("prod-a") &&
|
|
45
|
+
(query.includes("camunda_jobs_executable") || query.includes("camunda_external_tasks_open"))
|
|
46
|
+
? [{ metric: { engine_id: "prod-a" }, value: 0 }]
|
|
47
|
+
: []
|
|
48
|
+
|
|
49
|
+
describe("analytics_engine_health — engine scope (N138)", () => {
|
|
50
|
+
it("with engine omitted, judges exactly the configured fleet — never every engine Prometheus holds", async () => {
|
|
51
|
+
const { client, queries } = recordingClient()
|
|
52
|
+
const result = await run(client, {})
|
|
53
|
+
|
|
54
|
+
expect(result.engines).toEqual(["prod-a", "prod-b"])
|
|
55
|
+
expect(queries.every((q) => q.includes('engine_id=~"prod-a|prod-b"'))).toBe(true)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("refuses an engine id this server is not configured for, before any query", async () => {
|
|
59
|
+
const { client, queries } = recordingClient()
|
|
60
|
+
await expect(run(client, { engine: "tenant-x" })).rejects.toThrow(
|
|
61
|
+
/Unknown engine "tenant-x".*configured engines: prod-a, prod-b/,
|
|
62
|
+
)
|
|
63
|
+
expect(queries).toEqual([])
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe("analytics_engine_health — verdict (N76)", () => {
|
|
68
|
+
it("reads a Prometheus without any metrics of the fleet as unknown, never healthy", async () => {
|
|
69
|
+
const { client } = recordingClient()
|
|
70
|
+
const result = await run(client, {})
|
|
71
|
+
expect(result.status).toBe("unknown")
|
|
72
|
+
expect(result.silentEngines).toEqual(["prod-a", "prod-b"])
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("reads a silent engine as unknown, not as an idle healthy one", async () => {
|
|
76
|
+
const { client } = recordingClient(onlyProdAReports)
|
|
77
|
+
const result = await run(client, { engine: "prod-b" })
|
|
78
|
+
expect(result).toMatchObject({
|
|
79
|
+
status: "unknown",
|
|
80
|
+
engines: ["prod-b"],
|
|
81
|
+
reportingEngines: [],
|
|
82
|
+
silentEngines: ["prod-b"],
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("degrades the fleet verdict while one configured engine is silent", async () => {
|
|
87
|
+
const { client } = recordingClient(onlyProdAReports)
|
|
88
|
+
const result = await run(client, {})
|
|
89
|
+
expect(result).toMatchObject({
|
|
90
|
+
status: "degraded",
|
|
91
|
+
reportingEngines: ["prod-a"],
|
|
92
|
+
silentEngines: ["prod-b"],
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
describe("analytics_engine_health — alerts (N76)", () => {
|
|
98
|
+
const alertQueries = (queries: string[]) => queries.filter((q) => q.includes("ALERTS"))
|
|
99
|
+
|
|
100
|
+
it("scopes one engine's verdict to that engine's alerts only", async () => {
|
|
101
|
+
const { client, queries } = recordingClient()
|
|
102
|
+
await run(client, { engine: "prod-a" })
|
|
103
|
+
expect(alertQueries(queries)).toEqual([
|
|
104
|
+
'ALERTS{alertstate="firing",engine_id="prod-a"}',
|
|
105
|
+
'ALERTS{alertstate="pending",engine_id="prod-a"}',
|
|
106
|
+
])
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it.each([
|
|
110
|
+
["engine omitted", {}, "prod-a|prod-b"],
|
|
111
|
+
["every configured engine named", { engine: ["prod-b", "prod-a"] }, "prod-b|prod-a"],
|
|
112
|
+
])(
|
|
113
|
+
"adds the module's own engine-less alerts to a verdict over the whole fleet (%s)",
|
|
114
|
+
async (_case, args, ids) => {
|
|
115
|
+
const { client, queries } = recordingClient()
|
|
116
|
+
await run(client, args)
|
|
117
|
+
expect(alertQueries(queries)[0]).toBe(
|
|
118
|
+
`ALERTS{alertstate="firing",engine_id=~"${ids}"} or ` +
|
|
119
|
+
'ALERTS{alertstate="firing",alertname=~"CibSeven.+",engine_id=""}',
|
|
120
|
+
)
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
it("never lets a foreign critical alert in a shared Prometheus decide the verdict", async () => {
|
|
125
|
+
// A critical alert of somebody else's rule, carrying no engine_id: the
|
|
126
|
+
// scoped selector cannot match it, so it never reaches the verdict.
|
|
127
|
+
const { client, queries } = recordingClient(onlyProdAReports)
|
|
128
|
+
const result = await run(client, { engine: "prod-a" })
|
|
129
|
+
expect(alertQueries(queries).some((q) => !q.includes('engine_id="prod-a"'))).toBe(false)
|
|
130
|
+
expect(result.status).toBe("healthy")
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* #340: two health tools judge different data by different rules — each
|
|
136
|
+
* states its own in the description AND the result, and routes to the other.
|
|
137
|
+
*/
|
|
138
|
+
describe("analytics_engine_health verdict labeling", () => {
|
|
139
|
+
it("carries the status rule — including the unknown verdict — in every result", async () => {
|
|
140
|
+
const { client } = recordingClient()
|
|
141
|
+
const result = await run(client, {})
|
|
142
|
+
expect(result.statusRule).toBe(ENGINE_HEALTH_STATUS_RULE)
|
|
143
|
+
expect(ENGINE_HEALTH_STATUS_RULE).toMatch(
|
|
144
|
+
/^From Prometheus, over the engines in scope: unknown when none of them reports metrics;/,
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it("states the rule, the engine scope and the routing to camunda7_show_engine_health", () => {
|
|
149
|
+
const { description } = captureConfig()
|
|
150
|
+
expect(description).toContain(`Status: ${ENGINE_HEALTH_STATUS_RULE}`)
|
|
151
|
+
expect(description).toContain("the silent ones (`silentEngines`")
|
|
152
|
+
expect(description).toContain(
|
|
153
|
+
"Use for fleet-wide or metric-based health; for one engine's live incident clusters use camunda7_show_engine_health.",
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { PrometheusClient } from "@miragon/pulse-analytics-client"
|
|
2
|
+
import { ENGINE_HEALTH_STATUS_RULE, schemas, queries } from "@miragon/pulse-analytics-client"
|
|
3
|
+
import type { createToolRegistrar } from "@miragon/mcp-toolkit-core/tools"
|
|
4
|
+
import type { AnalyticsEngineScope } from "../engine-ids.js"
|
|
5
|
+
|
|
6
|
+
type Register = ReturnType<typeof createToolRegistrar<PrometheusClient>>
|
|
7
|
+
|
|
8
|
+
export function registerHealthTools(register: Register, engineScope: AnalyticsEngineScope) {
|
|
9
|
+
register({
|
|
10
|
+
name: "analytics_engine_health",
|
|
11
|
+
category: "analytics",
|
|
12
|
+
description:
|
|
13
|
+
"Live operational health of the CIB Seven engine(s), from Prometheus state gauges: running WIP, open incidents by type, dead jobs (retries exhausted), executable/suspended job backlog, open/unassigned user tasks, external-task backlog, deployed definitions, and which alert rules are firing/pending. " +
|
|
14
|
+
"Names the engines judged and the silent ones (`silentEngines`: their counts read 0 because nothing arrives). " +
|
|
15
|
+
`Status: ${ENGINE_HEALTH_STATUS_RULE} ` +
|
|
16
|
+
"Use for fleet-wide or metric-based health; for one engine's live incident clusters use camunda7_show_engine_health.",
|
|
17
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
18
|
+
inputSchema: schemas.engineHealthInput.shape,
|
|
19
|
+
handler: async (ch, args) => {
|
|
20
|
+
const engines = engineScope.resolve(args.engine)
|
|
21
|
+
// The module's engine-less alerts (a collector outage) only count for a
|
|
22
|
+
// verdict over the whole configured fleet.
|
|
23
|
+
return queries.engineHealth(ch, {
|
|
24
|
+
engine: engines,
|
|
25
|
+
includeFleetAlerts: engineScope.coversFleet(engines),
|
|
26
|
+
})
|
|
27
|
+
},
|
|
28
|
+
})
|
|
29
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { MCPServer } from "mcp-use"
|
|
2
|
+
import { createToolRegistrar } from "@miragon/mcp-toolkit-core/tools"
|
|
3
|
+
import type { PrometheusClient } from "@miragon/pulse-analytics-client"
|
|
4
|
+
import type { ProfileSource } from "../server-locale.js"
|
|
5
|
+
import type { AnalyticsEngineScope } from "../engine-ids.js"
|
|
6
|
+
import { registerPerformanceTools } from "./performance.js"
|
|
7
|
+
import { registerFailureTools } from "./failures.js"
|
|
8
|
+
import { registerElementTools } from "./element.js"
|
|
9
|
+
import { registerClusterCompareTools } from "./cluster-compare.js"
|
|
10
|
+
import { registerVersionCompareTools } from "./version-compare.js"
|
|
11
|
+
import { registerEngineCompareTools } from "./engine-compare.js"
|
|
12
|
+
import { registerEngineLandscapeTools } from "./engine-landscape.js"
|
|
13
|
+
import { registerHealthTools } from "./health.js"
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `profileStore` feeds the saved analytics defaults (`modules.analytics`) into
|
|
17
|
+
* every tool that takes `period`/`minBucketSize` — the same "explicit arg >
|
|
18
|
+
* saved setting > schema default" resolution as the widget tools, so the
|
|
19
|
+
* settings contract holds across the whole tool surface. The handlers pass the
|
|
20
|
+
* ctx the registrar hands them to `settingsFor`, so the caller resolves
|
|
21
|
+
* exactly like the settings tools' own (`resolveProfileKey`).
|
|
22
|
+
*
|
|
23
|
+
* `engineScope` holds the server's configured engine ids: every tool resolves
|
|
24
|
+
* its `engine` argument through it (omitted = all of them), so no query ever
|
|
25
|
+
* reads an engine this server is not configured for (`engine-ids.ts`).
|
|
26
|
+
*/
|
|
27
|
+
export function registerTools(
|
|
28
|
+
server: MCPServer,
|
|
29
|
+
client: PrometheusClient,
|
|
30
|
+
engineScope: AnalyticsEngineScope,
|
|
31
|
+
profileStore?: ProfileSource,
|
|
32
|
+
): void {
|
|
33
|
+
// Strict input: an unknown (e.g. misnamed) key is a tool error naming the
|
|
34
|
+
// valid keys instead of a silently dropped filter (#329).
|
|
35
|
+
const register = createToolRegistrar(server, client, { strictInput: true })
|
|
36
|
+
registerPerformanceTools(register, engineScope, profileStore)
|
|
37
|
+
registerFailureTools(register, engineScope)
|
|
38
|
+
registerElementTools(register, engineScope, profileStore)
|
|
39
|
+
registerClusterCompareTools(register, engineScope, profileStore)
|
|
40
|
+
registerVersionCompareTools(register, engineScope, profileStore)
|
|
41
|
+
registerEngineCompareTools(register, engineScope, profileStore)
|
|
42
|
+
registerEngineLandscapeTools(register, engineScope)
|
|
43
|
+
registerHealthTools(register, engineScope)
|
|
44
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"
|
|
2
|
+
import { increaseIn, type PrometheusClient } from "@miragon/pulse-analytics-client"
|
|
3
|
+
import type { RegisteredToolMeta, ToolConfig } from "@miragon/mcp-toolkit-core/tools"
|
|
4
|
+
import { createEngineScope } from "../engine-ids.js"
|
|
5
|
+
import { registerPerformanceTools } from "./performance.js"
|
|
6
|
+
|
|
7
|
+
const scope = createEngineScope(["prod-a", "prod-b"])
|
|
8
|
+
const register = (r: never) => registerPerformanceTools(r, scope)
|
|
9
|
+
|
|
10
|
+
type Handler = ToolConfig<PrometheusClient>["handler"]
|
|
11
|
+
|
|
12
|
+
/** Registrar stand-in that captures each tool's handler instead of an MCPServer. */
|
|
13
|
+
function captureHandlers(registerModule: (register: never) => void): Map<string, Handler> {
|
|
14
|
+
const handlers = new Map<string, Handler>()
|
|
15
|
+
const register = Object.assign(
|
|
16
|
+
(config: ToolConfig<PrometheusClient>) => {
|
|
17
|
+
handlers.set(config.name, config.handler)
|
|
18
|
+
},
|
|
19
|
+
{ getRegisteredTools: (): RegisteredToolMeta[] => [] },
|
|
20
|
+
)
|
|
21
|
+
registerModule(register as never)
|
|
22
|
+
return handlers
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** PrometheusClient that records every instant PromQL string and returns no samples. */
|
|
26
|
+
function recordingClient(): { client: PrometheusClient; queries: string[] } {
|
|
27
|
+
const queries: string[] = []
|
|
28
|
+
return {
|
|
29
|
+
client: {
|
|
30
|
+
instant: (query: string) => {
|
|
31
|
+
queries.push(query)
|
|
32
|
+
return Promise.resolve([])
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
queries,
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("analytics_analyze_process_performance PromQL", () => {
|
|
40
|
+
it("emits the exact KPI + activity-breakdown queries, scoped to key and engine", async () => {
|
|
41
|
+
const handlers = captureHandlers(register)
|
|
42
|
+
const { client, queries } = recordingClient()
|
|
43
|
+
|
|
44
|
+
await handlers.get("analytics_analyze_process_performance")!(client, {
|
|
45
|
+
processDefinitionKey: "order",
|
|
46
|
+
period: "7d",
|
|
47
|
+
includeActivityBreakdown: true,
|
|
48
|
+
engine: "prod-a",
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const sel = '{process_definition_key="order",engine_id="prod-a"}'
|
|
52
|
+
const completedSel = '{process_definition_key="order",engine_id="prod-a",state="COMPLETED"}'
|
|
53
|
+
const count = (series: string) => increaseIn(series, { range: "7d" })
|
|
54
|
+
const instance = "camunda_process_instance_duration_milliseconds"
|
|
55
|
+
const activity = "camunda_activity_duration_milliseconds"
|
|
56
|
+
expect(queries).toEqual([
|
|
57
|
+
`sum(${count(`camunda_process_instance_started_total${sel}`)})`,
|
|
58
|
+
`sum(${count(`camunda_process_instance_ended_total${completedSel}`)})`,
|
|
59
|
+
`sum(${count(`camunda_incident_created_total${sel}`)})`,
|
|
60
|
+
`sum(${count(`${instance}_sum${sel}`)}) / sum(${count(`${instance}_count${sel}`)})`,
|
|
61
|
+
`histogram_quantile(0.5, sum by (le)(${count(`${instance}_bucket${sel}`)}))`,
|
|
62
|
+
`histogram_quantile(0.95, sum by (le)(${count(`${instance}_bucket${sel}`)}))`,
|
|
63
|
+
`sum by (activity_id, activity_type)(${count(`camunda_activity_ended_total${sel}`)})`,
|
|
64
|
+
`sum by (activity_id)(${count(`${activity}_sum${sel}`)})`,
|
|
65
|
+
`histogram_quantile(0.5, sum by (activity_id, le)(${count(`${activity}_bucket${sel}`)}))`,
|
|
66
|
+
`histogram_quantile(0.95, sum by (activity_id, le)(${count(`${activity}_bucket${sel}`)}))`,
|
|
67
|
+
])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it("skips the activity-breakdown queries; engine omitted reads the configured fleet", async () => {
|
|
71
|
+
const handlers = captureHandlers(register)
|
|
72
|
+
const { client, queries } = recordingClient()
|
|
73
|
+
|
|
74
|
+
await handlers.get("analytics_analyze_process_performance")!(client, {
|
|
75
|
+
processDefinitionKey: "order",
|
|
76
|
+
period: "30d",
|
|
77
|
+
includeActivityBreakdown: false,
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
expect(queries).toHaveLength(6)
|
|
81
|
+
expect(queries.every((q) => q.includes('process_definition_key="order"'))).toBe(true)
|
|
82
|
+
expect(queries.every((q) => q.includes("[30d]"))).toBe(true)
|
|
83
|
+
// Never every engine a shared Prometheus holds (N138).
|
|
84
|
+
expect(queries.every((q) => q.includes('engine_id=~"prod-a|prod-b"'))).toBe(true)
|
|
85
|
+
expect(queries.some((q) => q.includes("camunda_activity_"))).toBe(false)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe("analytics_compare_execution_periods PromQL", () => {
|
|
90
|
+
// Explicit windows are clamped to [now − retention, now] — pin now.
|
|
91
|
+
beforeAll(() => {
|
|
92
|
+
vi.useFakeTimers({ toFake: ["Date"], now: Date.parse("2026-02-10T00:00:00Z") })
|
|
93
|
+
})
|
|
94
|
+
afterAll(() => {
|
|
95
|
+
vi.useRealTimers()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("queries each period via `[duration] @ end` historical windows", async () => {
|
|
99
|
+
const handlers = captureHandlers(register)
|
|
100
|
+
const { client, queries } = recordingClient()
|
|
101
|
+
|
|
102
|
+
await handlers.get("analytics_compare_execution_periods")!(client, {
|
|
103
|
+
processDefinitionKey: "order",
|
|
104
|
+
periodAFrom: "2026-01-20T00:00:00Z",
|
|
105
|
+
periodATo: "2026-01-21T00:00:00Z",
|
|
106
|
+
periodBFrom: "2026-02-01T00:00:00Z",
|
|
107
|
+
periodBTo: "2026-02-03T00:00:00Z",
|
|
108
|
+
includeActivityBreakdown: false,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const atA = Date.parse("2026-01-21T00:00:00Z") / 1000
|
|
112
|
+
const atB = Date.parse("2026-02-03T00:00:00Z") / 1000
|
|
113
|
+
const windowA = `[86400s] @ ${atA}`
|
|
114
|
+
const windowB = `[172800s] @ ${atB}`
|
|
115
|
+
const fleet = 'engine_id=~"prod-a|prod-b"'
|
|
116
|
+
// 6 KPI queries per period, period A first.
|
|
117
|
+
expect(queries).toHaveLength(12)
|
|
118
|
+
expect(queries.slice(0, 6).every((q) => q.includes(windowA))).toBe(true)
|
|
119
|
+
expect(queries.slice(6).every((q) => q.includes(windowB))).toBe(true)
|
|
120
|
+
const started = `camunda_process_instance_started_total{process_definition_key="order",${fleet}}`
|
|
121
|
+
expect(queries[0]).toBe(`sum(${increaseIn(started, { range: "86400s", at: atA })})`)
|
|
122
|
+
expect(queries[6]).toBe(`sum(${increaseIn(started, { range: "172800s", at: atB })})`)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
describe("analytics_compare_execution_periods windows (N79)", () => {
|
|
127
|
+
beforeAll(() => {
|
|
128
|
+
vi.useFakeTimers({ toFake: ["Date"], now: Date.parse("2026-02-10T00:00:00Z") })
|
|
129
|
+
})
|
|
130
|
+
afterAll(() => {
|
|
131
|
+
vi.useRealTimers()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const base = { processDefinitionKey: "order", includeActivityBreakdown: false }
|
|
135
|
+
|
|
136
|
+
it("clamps a window reaching past now to now and reports what was measured", async () => {
|
|
137
|
+
const handlers = captureHandlers(register)
|
|
138
|
+
const { client, queries } = recordingClient()
|
|
139
|
+
|
|
140
|
+
const result = (await handlers.get("analytics_compare_execution_periods")!(client, {
|
|
141
|
+
...base,
|
|
142
|
+
periodAFrom: "2026-02-01T00:00:00Z",
|
|
143
|
+
periodATo: "2026-02-05T00:00:00Z",
|
|
144
|
+
periodBFrom: "2026-02-05T00:00:00Z",
|
|
145
|
+
periodBTo: "2026-02-15T00:00:00Z",
|
|
146
|
+
})) as { kpiComparison: Array<{ window_to: string; window_days: number; partial: boolean }> }
|
|
147
|
+
|
|
148
|
+
const now = Date.parse("2026-02-10T00:00:00Z") / 1000
|
|
149
|
+
// No `@` anchor in the future: Prometheus would answer with the elapsed
|
|
150
|
+
// part while the window is reported at full length.
|
|
151
|
+
expect(queries.slice(6).every((q) => q.includes(`[432000s] @ ${now}`))).toBe(true)
|
|
152
|
+
expect(result.kpiComparison[1]).toMatchObject({
|
|
153
|
+
window_to: "2026-02-10T00:00:00.000Z",
|
|
154
|
+
window_days: 5,
|
|
155
|
+
partial: true,
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("refuses a reversed window instead of measuring a 1-second one", async () => {
|
|
160
|
+
const handlers = captureHandlers(register)
|
|
161
|
+
const { client, queries } = recordingClient()
|
|
162
|
+
await expect(
|
|
163
|
+
handlers.get("analytics_compare_execution_periods")!(client, {
|
|
164
|
+
...base,
|
|
165
|
+
periodAFrom: "2026-02-05T00:00:00Z",
|
|
166
|
+
periodATo: "2026-02-01T00:00:00Z",
|
|
167
|
+
periodBFrom: "2026-02-05T00:00:00Z",
|
|
168
|
+
periodBTo: "2026-02-08T00:00:00Z",
|
|
169
|
+
}),
|
|
170
|
+
).rejects.toThrow(/periodA: the window ends before it starts/)
|
|
171
|
+
expect(queries).toEqual([])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("refuses a window entirely before the retention", async () => {
|
|
175
|
+
const handlers = captureHandlers(register)
|
|
176
|
+
const { client } = recordingClient()
|
|
177
|
+
await expect(
|
|
178
|
+
handlers.get("analytics_compare_execution_periods")!(client, {
|
|
179
|
+
...base,
|
|
180
|
+
periodAFrom: "2025-10-01T00:00:00Z",
|
|
181
|
+
periodATo: "2025-10-08T00:00:00Z",
|
|
182
|
+
periodBFrom: "2026-02-01T00:00:00Z",
|
|
183
|
+
periodBTo: "2026-02-08T00:00:00Z",
|
|
184
|
+
}),
|
|
185
|
+
).rejects.toThrow(/periodA: the window lies before the 30-day Prometheus retention/)
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
describe("analytics_analyze_process_performance saved defaults", () => {
|
|
190
|
+
it("resolves the CALLER's saved period from the handler ctx the registrar hands it", async () => {
|
|
191
|
+
const store = {
|
|
192
|
+
get: (key: string) =>
|
|
193
|
+
Promise.resolve(
|
|
194
|
+
key === "user-1" ? { modules: { analytics: { defaultPeriod: "30d" } } } : undefined,
|
|
195
|
+
),
|
|
196
|
+
}
|
|
197
|
+
const handlers = captureHandlers((r) => registerPerformanceTools(r, scope, store))
|
|
198
|
+
const handler = handlers.get("analytics_analyze_process_performance")!
|
|
199
|
+
const firstQueryFor = async (ctx?: unknown) => {
|
|
200
|
+
const { client, queries } = recordingClient()
|
|
201
|
+
await handler(client, { processDefinitionKey: "order" }, ctx as never)
|
|
202
|
+
return queries[0]
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
expect(await firstQueryFor({ auth: { user: { id: "user-1" } } })).toContain("[30d]")
|
|
206
|
+
// Another caller, and a request without any identity, get the schema default.
|
|
207
|
+
expect(await firstQueryFor({ auth: { user: { id: "user-2" } } })).toContain("[7d]")
|
|
208
|
+
expect(await firstQueryFor()).toContain("[7d]")
|
|
209
|
+
})
|
|
210
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { PrometheusClient } from "@miragon/pulse-analytics-client"
|
|
2
|
+
import { schemas, queries } from "@miragon/pulse-analytics-client"
|
|
3
|
+
import type { createToolRegistrar } from "@miragon/mcp-toolkit-core/tools"
|
|
4
|
+
import type { ProfileSource } from "../server-locale.js"
|
|
5
|
+
import { optionalPeriod, settingsFor } from "../settings.js"
|
|
6
|
+
import { withEngineScope, type AnalyticsEngineScope } from "../engine-ids.js"
|
|
7
|
+
|
|
8
|
+
type Register = ReturnType<typeof createToolRegistrar<PrometheusClient>>
|
|
9
|
+
|
|
10
|
+
export function registerPerformanceTools(
|
|
11
|
+
register: Register,
|
|
12
|
+
engineScope: AnalyticsEngineScope,
|
|
13
|
+
profileStore?: ProfileSource,
|
|
14
|
+
) {
|
|
15
|
+
register({
|
|
16
|
+
name: "analytics_analyze_process_performance",
|
|
17
|
+
category: "analytics",
|
|
18
|
+
description:
|
|
19
|
+
"Analyze process performance from metrics over a rolling window: starts, completions, incidents created (incidents, not failed instances) per 100 starts, P50/P95 duration of the instances that ENDED in the window (null when none did), and a per-activity breakdown.",
|
|
20
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
21
|
+
inputSchema: { ...schemas.analyzePerformanceInput.shape, period: optionalPeriod },
|
|
22
|
+
handler: async (ch, args, ctx) =>
|
|
23
|
+
queries.analyzePerformance(ch, {
|
|
24
|
+
...withEngineScope(engineScope, args),
|
|
25
|
+
period: args.period ?? (await settingsFor(profileStore, ctx)).defaultPeriod,
|
|
26
|
+
}),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
register({
|
|
30
|
+
name: "analytics_compare_execution_periods",
|
|
31
|
+
category: "analytics",
|
|
32
|
+
description:
|
|
33
|
+
"Compare process execution metrics between two explicit time windows (before/after a change, regression analysis), using PromQL historical windows. Windows are clamped to now and the 30-day retention and reported as measured (`partial` when cut short); a reversed or wholly out-of-range window is refused. Compare windows of different length by started_per_day.",
|
|
34
|
+
annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true },
|
|
35
|
+
inputSchema: schemas.comparePeriodsInput.shape,
|
|
36
|
+
handler: async (ch, args) => queries.comparePeriods(ch, withEngineScope(engineScope, args)),
|
|
37
|
+
})
|
|
38
|
+
}
|