Skip to content

Commit a8cf611

Browse files
otAAAhJenkins
authored andcommitted
feat(monitor): explain a service with AI
The service slide-in now offers "Explain with AI" as an ai variant button. It hands the shown service to the AiExplainButtonApp, which the cloud edition mounts on the page and which owns the conversation. The feature ships in the cloud edition only: monitor code keeps a no-op AiExplain that the cloud registration fills in, so every other edition renders no button. CMK-37516 Change-Id: I90a418bb53f93cb44f98e54acc212928ab10c66b
1 parent da14b7e commit a8cf611

7 files changed

Lines changed: 156 additions & 6 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
#!/usr/bin/env python3
2+
# Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2
3+
# This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
4+
# conditions defined in the file COPYING, which is part of this source code package.
5+
from collections.abc import Callable
6+
7+
8+
class AiExplain:
9+
"""Entry point for the cloud edition's "Explain with AI" feature.
10+
11+
The service slide-in offers the explanation, but the feature itself ships in
12+
the cloud edition only. The cloud registration supplies the two callables
13+
below; every other edition keeps the no-op default and renders no button.
14+
"""
15+
16+
def __init__(self) -> None:
17+
self._is_enabled: Callable[[], bool] = lambda: False
18+
self._render_listener: Callable[[], None] = lambda: None
19+
20+
def register(
21+
self,
22+
is_enabled: Callable[[], bool],
23+
render_listener: Callable[[], None],
24+
) -> None:
25+
self._is_enabled = is_enabled
26+
self._render_listener = render_listener
27+
28+
def is_enabled(self) -> bool:
29+
return self._is_enabled()
30+
31+
def render_listener(self) -> None:
32+
if self._is_enabled():
33+
self._render_listener()
34+
35+
36+
ai_explain = AiExplain()

cmk/gui/monitor/services/_pages/_monitor_host_services.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from cmk.gui.logged_in import user
1515
from cmk.gui.main_menu import main_menu_registry
1616
from cmk.gui.monitor.command import MonitorCommands
17+
from cmk.gui.monitor.services._ai_explain import ai_explain
1718
from cmk.gui.page_menu import PageMenu
1819
from cmk.gui.pages import Page, PageContext
1920
from cmk.gui.pagetypes import PagetypeTopics
@@ -72,6 +73,7 @@ def page(self, ctx: PageContext) -> None:
7273
may_ignore_hard_limit=user.may("general.ignore_hard_limit"),
7374
host=hostname,
7475
site=site_id,
76+
ai_explain=ai_explain.is_enabled(),
7577
actions=self._permitted_actions(),
7678
legacy_view_button=MonitoringPageLinkButton(
7779
url=makeuri_contextless(
@@ -85,6 +87,8 @@ def page(self, ctx: PageContext) -> None:
8587
),
8688
)
8789

90+
ai_explain.render_listener()
91+
8892
html.footer()
8993

9094

packages/cmk-frontend-vue/src/monitoring/host-services/HostServicesApp.vue

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,12 @@ function onActionPerformed(result: ActionFeedbackResult): void {
151151
<HostServicesRow :row="row" :table-row="tableRow" @open="openSlideIn" />
152152
</template>
153153
</MonitoringSplitPane>
154-
<ServiceSlideIn :service="slideInService" :host="host" @close="closeSlideIn" />
154+
<ServiceSlideIn
155+
:service="slideInService"
156+
:host="host"
157+
:ai-explain="props.ai_explain ?? false"
158+
@close="closeSlideIn"
159+
/>
155160
</div>
156161
</template>
157162

packages/cmk-frontend-vue/src/monitoring/host-services/components/ServiceSlideIn.vue

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,21 @@ import { HostServicesApi } from '@/monitoring/host-services/api/services'
1212
import type { HostRef, HostServiceEntry, ServiceOverview } from '@/monitoring/shared/api/types'
1313
import type { CellAction } from '@/monitoring/shared/components/cell/ActionButtons.vue'
1414
15+
import ServiceAiExplainButton from './slide-in/ServiceAiExplainButton.vue'
1516
import ServiceOverviewSkeleton from './slide-in/ServiceOverviewSkeleton.vue'
1617
import ServiceOverviewTab from './slide-in/ServiceOverviewTab.vue'
1718
import ServiceSlideInHeader from './slide-in/ServiceSlideInHeader.vue'
1819
19-
const props = defineProps<{
20-
/** The service to detail. `null` keeps the slide-in closed. */
21-
service: HostServiceEntry | null
22-
host: HostRef
23-
}>()
20+
const props = withDefaults(
21+
defineProps<{
22+
/** The service to detail. `null` keeps the slide-in closed. */
23+
service: HostServiceEntry | null
24+
host: HostRef
25+
/** Offer the cloud edition's "Explain with AI" action. */
26+
aiExplain?: boolean
27+
}>(),
28+
{ aiExplain: false }
29+
)
2430
2531
const emit = defineEmits<{
2632
(event: 'close'): void
@@ -114,6 +120,7 @@ const tabs = computed<SlideInTab[]>(() => {
114120
:modes="overview?.modes ?? []"
115121
:actions="inlineActions"
116122
/>
123+
<ServiceAiExplainButton v-if="aiExplain && overview" :overview="overview" />
117124
</template>
118125
</CmkSlideInTabbed>
119126
</template>
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<!--
2+
Copyright (C) 2026 Checkmk GmbH - License: GNU General Public License v2
3+
This file is part of Checkmk (https://checkmk.com). It is subject to the terms and
4+
conditions defined in the file COPYING, which is part of this source code package.
5+
-->
6+
<script setup lang="ts">
7+
import type { ExplainThisIssueData } from 'cmk-shared-typing/typescript/ai_button'
8+
import CmkButton from 'cmk-ui-library/components/CmkButton'
9+
import usei18n from 'cmk-ui-library/lib/i18n'
10+
11+
import type { ServiceOverview } from '@/monitoring/shared/api/types'
12+
13+
const { _t } = usei18n()
14+
15+
const props = defineProps<{
16+
overview: ServiceOverview
17+
}>()
18+
19+
const SERVICE_STATES: Record<ServiceOverview['state'], ExplainThisIssueData['service_state']> = {
20+
OK: 'OK',
21+
WARN: 'Warning',
22+
CRIT: 'Critical',
23+
UNKNOWN: 'Unknown'
24+
}
25+
26+
const HOST_STATES: Record<ServiceOverview['host_state'], ExplainThisIssueData['host_state']> = {
27+
UP: 'Up',
28+
DOWN: 'Down',
29+
UNREACHABLE: 'Unreachable'
30+
}
31+
32+
function explainThis(): void {
33+
const detail: ExplainThisIssueData = {
34+
host_name: props.overview.host_name,
35+
service_name: props.overview.name,
36+
service_state: SERVICE_STATES[props.overview.state],
37+
host_state: HOST_STATES[props.overview.host_state]
38+
}
39+
document.dispatchEvent(new CustomEvent('cmk-ai-explain-button', { detail }))
40+
}
41+
42+
defineExpose({ explainThis })
43+
</script>
44+
45+
<template>
46+
<CmkButton
47+
variant="ai"
48+
:icon="{ name: 'sparkle' }"
49+
data-testid="service-ai-explain-button"
50+
@click="explainThis"
51+
>
52+
{{ _t('Explain with AI') }}
53+
</CmkButton>
54+
</template>

packages/cmk-frontend-vue/tests/monitoring/host-services/components/ServiceSlideIn.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66
import userEvent from '@testing-library/user-event'
77
import { render, screen } from '@testing-library/vue'
8+
import type { ExplainThisIssueData } from 'cmk-shared-typing/typescript/ai_button'
89
import client from 'cmk-ui-library/lib/rest-api-client/client'
910
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
1011

@@ -195,6 +196,45 @@ describe('ServiceSlideIn', () => {
195196
).not.toBeInTheDocument()
196197
})
197198

199+
it('offers no AI explanation outside the cloud edition', async () => {
200+
render(ServiceSlideIn, { props: { service: makeService(), host: HOST } })
201+
await screen.findByText('Service details')
202+
203+
expect(screen.queryByTestId('service-ai-explain-button')).not.toBeInTheDocument()
204+
})
205+
206+
it('asks the AI app to explain the shown service', async () => {
207+
const explainRequests: ExplainThisIssueData[] = []
208+
const listener = (event: Event) => {
209+
explainRequests.push((event as CustomEvent<ExplainThisIssueData>).detail)
210+
}
211+
document.addEventListener('cmk-ai-explain-button', listener)
212+
render(ServiceSlideIn, {
213+
props: { service: makeService(), host: HOST, aiExplain: true }
214+
})
215+
216+
await userEvent.click(await screen.findByTestId('service-ai-explain-button'))
217+
document.removeEventListener('cmk-ai-explain-button', listener)
218+
219+
expect(explainRequests).toEqual([
220+
{
221+
host_name: 'web-server-01',
222+
service_name: 'CPU load',
223+
service_state: 'OK',
224+
host_state: 'Up'
225+
}
226+
])
227+
})
228+
229+
it('waits for the overview before offering the AI explanation', () => {
230+
vi.spyOn(client, 'GET').mockReturnValue(new Promise(() => {}) as never)
231+
render(ServiceSlideIn, {
232+
props: { service: makeService(), host: HOST, aiExplain: true }
233+
})
234+
235+
expect(screen.queryByTestId('service-ai-explain-button')).not.toBeInTheDocument()
236+
})
237+
198238
it('emits close when the close button is used', async () => {
199239
const { emitted } = render(ServiceSlideIn, { props: { service: makeService(), host: HOST } })
200240
await screen.findByText('Service details')

packages/cmk-shared-typing/source/monitoring/host_services.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@
1010
},
1111
"site": {
1212
"type": "string"
13+
},
14+
"ai_explain": {
15+
"type": "boolean",
16+
"default": false
1317
}
1418
},
1519
"required": ["host", "site"]

0 commit comments

Comments
 (0)