Skip to content

Commit dfbe1c2

Browse files
committed
feat(mon-services): add mode indicator filter (downtime/ack/notifications/flapping)
Adds a tri-state boolean-group filter to the modes column of the Host Services table, mirroring the All Hosts page. Extends the Service domain model with notifications_enabled and is_flapping (only acknowledged/in_downtime were fetched before), adds a ServiceBooleanCondition to the services filter schema, and wires up the corresponding icons in both service mode builders. Also fixes a pre-existing bug in build_service_modes/ build_service_modes_by_id: the notifications-disabled icon was named "notif_disabled" (underscore), but the actual registered icon is "notif-disabled" (hyphen), so it likely never rendered. CMK-37406 Change-Id: I61d264b58767b40353f4a84c864fe2dbd00c911f
1 parent 0dfa973 commit dfbe1c2

10 files changed

Lines changed: 263 additions & 16 deletions

File tree

cmk/gui/monitor/services/_api/_filters.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,21 @@ class ServiceStateChoiceCondition:
5454
)
5555

5656

57-
type ServiceConditionNode = ServiceStateChoiceCondition | ServiceStringCondition
57+
@api_model
58+
class ServiceBooleanCondition:
59+
type: Literal["condition"] = api_field(
60+
description="Node type discriminator", example="condition"
61+
)
62+
field: Literal["acknowledged", "in_downtime", "notifications_enabled", "is_flapping"] = (
63+
api_field(description="Boolean service field to filter on", example="acknowledged")
64+
)
65+
op: Literal["eq"] = api_field(description="Equality operation", example="eq")
66+
value: bool = api_field(description="Boolean value to compare against", example=False)
67+
68+
69+
type ServiceConditionNode = (
70+
ServiceStateChoiceCondition | ServiceStringCondition | ServiceBooleanCondition
71+
)
5872

5973

6074
@api_model(slots=False)
@@ -116,6 +130,16 @@ def _accumulate_filters(node: ServiceFilterNode, filters: list[str]) -> None:
116130
case "one_of" if len(node.value) > 1:
117131
filters.append(f"Or: {len(node.value)}")
118132

133+
case ServiceBooleanCondition():
134+
match node.field:
135+
case "in_downtime":
136+
# Livestatus has no boolean downtime column; a service is in a scheduled
137+
# downtime when scheduled_downtime_depth is greater than zero.
138+
op = ">" if node.value else "="
139+
filters.append(f"Filter: scheduled_downtime_depth {op} 0")
140+
case _:
141+
filters.append(f"Filter: {node.field} = {int(node.value)}")
142+
119143
case ServiceAndNode() | ServiceOrNode():
120144
for child in node.children:
121145
_accumulate_filters(child, filters)

cmk/gui/monitor/services/_api/_modes.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,11 +46,19 @@ def build_service_modes(service: ServiceOverview) -> list[ServiceModeInfo]:
4646
if not service.notifications_enabled:
4747
modes.append(
4848
ServiceModeInfo(
49-
icon_name="notif_disabled",
49+
icon_name="notif-disabled",
5050
link=service_view_link("service", service),
5151
title=_("Notifications are disabled for this service"),
5252
)
5353
)
54+
if service.is_flapping:
55+
modes.append(
56+
ServiceModeInfo(
57+
icon_name="flapping",
58+
link=service_view_link("service", service),
59+
title=_("This service is flapping"),
60+
)
61+
)
5462
return modes
5563

5664

@@ -81,6 +89,26 @@ def build_service_modes_by_id(
8189
title=_("Problem acknowledged"),
8290
)
8391
)
92+
if not service.notifications_enabled:
93+
modes.append(
94+
ServiceModeInfo(
95+
icon_name="notif-disabled",
96+
link=service_view_link_by_id(
97+
"service", site_id=site_id, hostname=hostname, service_name=service.name
98+
),
99+
title=_("Notifications are disabled for this service"),
100+
)
101+
)
102+
if service.is_flapping:
103+
modes.append(
104+
ServiceModeInfo(
105+
icon_name="flapping",
106+
link=service_view_link_by_id(
107+
"service", site_id=site_id, hostname=hostname, service_name=service.name
108+
),
109+
title=_("This service is flapping"),
110+
)
111+
)
84112
return modes
85113

86114

cmk/gui/monitor/services/_impl.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ def fetch(
6969
Services.plugin_output,
7070
Services.acknowledged,
7171
Services.scheduled_downtime_depth,
72+
Services.notifications_enabled,
73+
Services.is_flapping,
7274
Services.last_check,
7375
Services.last_state_change,
7476
],
@@ -84,6 +86,8 @@ def fetch(
8486
state=ServiceState(row["state"]),
8587
acknowledged=bool(row["acknowledged"]),
8688
in_downtime=row["scheduled_downtime_depth"] > 0,
89+
notifications_enabled=bool(row["notifications_enabled"]),
90+
is_flapping=bool(row["is_flapping"]),
8791
summary=row["plugin_output"],
8892
last_check=(
8993
dt.datetime.fromtimestamp(row["last_check"], tz=dt.UTC)
@@ -111,6 +115,7 @@ def get_overview(self, *, hostname: str, service_name: str, site_id: str) -> Ser
111115
Services.acknowledged,
112116
Services.scheduled_downtime_depth,
113117
Services.notifications_enabled,
118+
Services.is_flapping,
114119
Services.host_alias,
115120
Services.host_state,
116121
Services.host_acknowledged,
@@ -148,6 +153,7 @@ def get_overview(self, *, hostname: str, service_name: str, site_id: str) -> Ser
148153
acknowledged=bool(row["acknowledged"]),
149154
in_downtime=row["scheduled_downtime_depth"] > 0,
150155
notifications_enabled=bool(row["notifications_enabled"]),
156+
is_flapping=bool(row["is_flapping"]),
151157
host_alias=row["host_alias"],
152158
host_state=HostState(row["host_state"]),
153159
host_acknowledged=bool(row["host_acknowledged"]),

cmk/gui/monitor/services/_models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ class Service:
4242
state: ServiceState
4343
acknowledged: bool
4444
in_downtime: bool
45+
notifications_enabled: bool
46+
is_flapping: bool
4547
summary: str
4648
last_check: dt.datetime | None
4749
last_state_change: dt.datetime
@@ -78,6 +80,7 @@ class ServiceOverview(Service):
7880
acknowledged: bool
7981
in_downtime: bool
8082
notifications_enabled: bool
83+
is_flapping: bool
8184
contact_groups: list[str]
8285
long_output: str
8386
current_attempt: int

packages/cmk-frontend-vue/src/monitoring/host-services/columns.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import usei18n from 'cmk-ui-library/lib/i18n'
88

99
import type { HostServiceEntry, ServiceState } from '@/monitoring/shared/api/types'
1010
import type {
11+
BooleanGroupFilter,
1112
CheckboxListFilter,
1213
StringInputFilter
1314
} from '@/monitoring/shared/components/filter/types'
@@ -44,6 +45,18 @@ export function useHostServicesColumns(): ColumnDef<HostServiceEntry>[] {
4445
field: 'summary'
4546
}
4647

48+
const modesFilter: BooleanGroupFilter<
49+
'in_downtime' | 'acknowledged' | 'notifications_enabled' | 'is_flapping'
50+
> = {
51+
type: 'boolean-group',
52+
groups: [
53+
{ field: 'in_downtime', title: _t('In downtime') },
54+
{ field: 'acknowledged', title: _t('Acknowledged') },
55+
{ field: 'notifications_enabled', title: _t('Notifications enabled') },
56+
{ field: 'is_flapping', title: _t('Flapping') }
57+
]
58+
}
59+
4760
return [
4861
{
4962
id: 'select',
@@ -69,7 +82,7 @@ export function useHostServicesColumns(): ColumnDef<HostServiceEntry>[] {
6982
enableSorting: false,
7083
minSize: 80,
7184
maxSize: 80,
72-
meta: { justify: 'left' }
85+
meta: { justify: 'left', filter: modesFilter }
7386
},
7487
{
7588
accessorKey: 'name',

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,53 @@ test('clearing the summary filter restores the full, unfiltered list', async ()
228228
)
229229
})
230230

231+
test('requests services that are not acknowledged and not in downtime', async () => {
232+
mockServices([makeApiEntry()])
233+
renderApp()
234+
235+
await userEvent.click(await screen.findByRole('button', { name: 'Filter Mode' }))
236+
const panel = screen.getByRole('group', { name: 'Filter Mode' })
237+
await userEvent.click(within(panel).getByLabelText('NOT Acknowledged'))
238+
await userEvent.click(within(panel).getByLabelText('NOT In downtime'))
239+
await userEvent.click(within(panel).getByRole('button', { name: 'Apply' }))
240+
241+
expect(postSpy).toHaveBeenLastCalledWith(
242+
'/monitor/hosts/{hostname}/services',
243+
expect.objectContaining({
244+
body: {
245+
limit: 1000,
246+
filter: {
247+
type: 'and',
248+
children: [
249+
{ type: 'condition', field: 'in_downtime', op: 'eq', value: false },
250+
{ type: 'condition', field: 'acknowledged', op: 'eq', value: false }
251+
]
252+
}
253+
}
254+
})
255+
)
256+
})
257+
258+
test('clearing the mode filter restores the full, unfiltered list', async () => {
259+
mockServices([makeApiEntry()])
260+
renderApp()
261+
262+
await userEvent.click(await screen.findByRole('button', { name: 'Filter Mode' }))
263+
let panel = screen.getByRole('group', { name: 'Filter Mode' })
264+
await userEvent.click(within(panel).getByLabelText('Flapping'))
265+
await userEvent.click(within(panel).getByRole('button', { name: 'Apply' }))
266+
267+
await userEvent.click(screen.getByRole('button', { name: 'Filter Mode' }))
268+
panel = screen.getByRole('group', { name: 'Filter Mode' })
269+
await userEvent.click(within(panel).getByRole('button', { name: 'Clear' }))
270+
await userEvent.click(within(panel).getByRole('button', { name: 'Apply' }))
271+
272+
expect(postSpy).toHaveBeenLastCalledWith(
273+
'/monitor/hosts/{hostname}/services',
274+
expect.objectContaining({ body: { limit: 1000 } })
275+
)
276+
})
277+
231278
test('resetting all filters restores the full, unfiltered list', async () => {
232279
mockServices([makeApiEntry()])
233280
renderApp()

tests/openapi/test_openapi_monitor_host_services.py

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,8 @@ def test_single_filter(
134134
"plugin_output": "WARN - load average: 3.10, 2.05, 1.01",
135135
"acknowledged": 0,
136136
"scheduled_downtime_depth": 0,
137+
"notifications_enabled": 1,
138+
"is_flapping": 0,
137139
"last_check": time.time(),
138140
"last_state_change": time.time(),
139141
}
@@ -219,6 +221,8 @@ def test_with_multiple_conditions(
219221
"plugin_output": "WARN - load average: 3.10, 2.05, 1.01",
220222
"acknowledged": 0,
221223
"scheduled_downtime_depth": 0,
224+
"notifications_enabled": 1,
225+
"is_flapping": 0,
222226
"last_check": time.time(),
223227
"last_state_change": time.time(),
224228
}
@@ -330,6 +334,8 @@ def test_services(
330334
"plugin_output": "OK - load average: 0.10, 0.05, 0.01",
331335
"acknowledged": 0,
332336
"scheduled_downtime_depth": 0,
337+
"notifications_enabled": 1,
338+
"is_flapping": 0,
333339
"last_check": time.time() - 30,
334340
"last_state_change": time.time(),
335341
}
@@ -403,6 +409,8 @@ def test_pending_service_has_no_last_check(
403409
"plugin_output": "",
404410
"acknowledged": 0,
405411
"scheduled_downtime_depth": 0,
412+
"notifications_enabled": 1,
413+
"is_flapping": 0,
406414
"last_check": 0,
407415
"last_state_change": time.time(),
408416
}
@@ -522,6 +530,8 @@ def test_services_without_limit(
522530
"plugin_output": "OK - load average: 0.10, 0.05, 0.01",
523531
"acknowledged": 0,
524532
"scheduled_downtime_depth": 0,
533+
"notifications_enabled": 1,
534+
"is_flapping": 0,
525535
"last_check": time.time(),
526536
"last_state_change": time.time(),
527537
}
@@ -584,6 +594,8 @@ def test_limit_removal_clamped_without_permission(
584594
"plugin_output": "OK - load average: 0.10, 0.05, 0.01",
585595
"acknowledged": 0,
586596
"scheduled_downtime_depth": 0,
597+
"notifications_enabled": 1,
598+
"is_flapping": 0,
587599
"last_check": time.time(),
588600
"last_state_change": time.time(),
589601
}
@@ -646,6 +658,8 @@ def test_limit_removal_honored_with_permission(
646658
"plugin_output": "OK - load average: 0.10, 0.05, 0.01",
647659
"acknowledged": 0,
648660
"scheduled_downtime_depth": 0,
661+
"notifications_enabled": 1,
662+
"is_flapping": 0,
649663
"last_check": time.time(),
650664
"last_state_change": time.time(),
651665
}
@@ -707,6 +721,7 @@ def test_returns_the_requested_service(
707721
"acknowledged": 0,
708722
"scheduled_downtime_depth": 0,
709723
"notifications_enabled": 1,
724+
"is_flapping": 0,
710725
"host_alias": _HOST_ALIAS,
711726
"host_state": 0,
712727
"host_acknowledged": 0,
@@ -775,12 +790,12 @@ def test_returns_the_requested_service(
775790
pytest.param({"acknowledged": 1}, ["ack"], id="problem acknowledged"),
776791
pytest.param(
777792
{"notifications_enabled": 0},
778-
["notif_disabled"],
793+
["notif-disabled"],
779794
id="notifications disabled",
780795
),
781796
pytest.param(
782797
{"scheduled_downtime_depth": 2, "acknowledged": 1, "notifications_enabled": 0},
783-
["downtime", "ack", "notif_disabled"],
798+
["downtime", "ack", "notif-disabled"],
784799
id="all modes at once",
785800
),
786801
],
@@ -806,6 +821,7 @@ def test_modes_reflect_the_service_state(
806821
"acknowledged": 0,
807822
"scheduled_downtime_depth": 0,
808823
"notifications_enabled": 1,
824+
"is_flapping": 0,
809825
"host_alias": _HOST_ALIAS,
810826
"host_state": 0,
811827
"host_acknowledged": 0,
@@ -859,6 +875,7 @@ def test_passive_service_has_no_next_check(
859875
"acknowledged": 0,
860876
"scheduled_downtime_depth": 0,
861877
"notifications_enabled": 1,
878+
"is_flapping": 0,
862879
"host_alias": _HOST_ALIAS,
863880
"host_state": 0,
864881
"host_acknowledged": 0,
@@ -911,6 +928,7 @@ def test_pending_service_has_no_last_check(
911928
"acknowledged": 0,
912929
"scheduled_downtime_depth": 0,
913930
"notifications_enabled": 1,
931+
"is_flapping": 0,
914932
"host_alias": _HOST_ALIAS,
915933
"host_state": 0,
916934
"host_acknowledged": 0,
@@ -1086,13 +1104,13 @@ def test_invalid_credentials(self, clients: ClientRegistry) -> None:
10861104
_HOST_ALIAS = "Web Server"
10871105
_SERVICE_OVERVIEW_COLUMNS = (
10881106
"description host_name state plugin_output last_check last_state_change acknowledged "
1089-
"scheduled_downtime_depth notifications_enabled host_alias host_state host_acknowledged "
1090-
"host_scheduled_downtime_depth contact_groups long_plugin_output current_attempt "
1091-
"max_check_attempts next_check tags labels label_sources"
1107+
"scheduled_downtime_depth notifications_enabled is_flapping host_alias host_state "
1108+
"host_acknowledged host_scheduled_downtime_depth contact_groups long_plugin_output "
1109+
"current_attempt max_check_attempts next_check tags labels label_sources"
10921110
)
10931111
_LIMIT = 1000
10941112
_SERVICES_COLUMNS = (
10951113
"description host_name state plugin_output acknowledged scheduled_downtime_depth "
1096-
"last_check last_state_change"
1114+
"notifications_enabled is_flapping last_check last_state_change"
10971115
)
10981116
_DEFAULT_ORDER_BY = "OrderBy: description asc natural"

0 commit comments

Comments
 (0)