netbox-checkmk 0.3.2__py3-none-any.whl

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.
@@ -0,0 +1,41 @@
1
+ from netbox.plugins import PluginConfig
2
+
3
+ __version__ = "0.3.2"
4
+
5
+
6
+ class NetBoxCheckmkConfig(PluginConfig):
7
+ name = "netbox_checkmk"
8
+ verbose_name = "Checkmk Status"
9
+ description = "Shows live Checkmk host and service status on devices, VMs and clusters"
10
+ version = __version__
11
+ author = "Christian Wirtz"
12
+ author_email = "doc@snowheaven.de"
13
+ base_url = "checkmk"
14
+ min_version = "4.3.0"
15
+ default_settings = {
16
+ # List of Checkmk sites, queried in order; the first site knowing a host wins:
17
+ # {"name": "main", "url": "https://cmk.example.com/mysite",
18
+ # "username": "automation", "secret": "...", "verify_ssl": True, "timeout": 5}
19
+ "sites": [],
20
+ # How often (seconds) NetBox fetches fresh state from Checkmk: lookups (also
21
+ # "host not found") are cached this long, and an open Checkmk tab reloads itself
22
+ # at this interval.
23
+ "check_interval": 300,
24
+ # Custom field (on devices/VMs/clusters) overriding the Checkmk host name.
25
+ "hostname_custom_field": "checkmk_hostname",
26
+ # Create that custom field (text, on devices/VMs/clusters) on `manage.py migrate`
27
+ # if it does not exist yet. An existing field is never modified.
28
+ "create_custom_field": True,
29
+ # Max. number of services listed on a device/VM tab (worst state first).
30
+ "max_services": 500,
31
+ # Max. number of member devices/VMs evaluated on a cluster tab.
32
+ "max_cluster_members": 100,
33
+ }
34
+
35
+ def ready(self):
36
+ super().ready()
37
+ # Registers the "Checkmk" tab views; must happen before NetBox builds its URLconf.
38
+ from . import signals, tables, views # noqa: F401
39
+
40
+
41
+ config = NetBoxCheckmkConfig
@@ -0,0 +1,272 @@
1
+ """Minimal Checkmk REST API client (monitoring status only).
2
+
3
+ Deliberately free of Django imports so it can be unit-tested standalone.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from urllib.parse import quote, urlencode
10
+
11
+ import requests
12
+
13
+ HOST_COLUMNS = (
14
+ "name",
15
+ "state",
16
+ "has_been_checked",
17
+ "plugin_output",
18
+ "acknowledged",
19
+ "scheduled_downtime_depth",
20
+ "last_check",
21
+ "num_services_ok",
22
+ "num_services_warn",
23
+ "num_services_crit",
24
+ "num_services_unknown",
25
+ "num_services_pending",
26
+ )
27
+
28
+ SERVICE_COLUMNS = (
29
+ "description",
30
+ "state",
31
+ "has_been_checked",
32
+ "plugin_output",
33
+ "acknowledged",
34
+ "scheduled_downtime_depth",
35
+ )
36
+
37
+ # Chunk size for multi-host queries, keeps single Livestatus queries reasonably small.
38
+ QUERY_CHUNK = 50
39
+
40
+ _REGEX_SPECIAL = set(".^$*+?()[]{}|\\")
41
+
42
+
43
+ def _regex_literal(value: str) -> str:
44
+ return "".join("\\" + c if c in _REGEX_SPECIAL else c for c in value)
45
+
46
+
47
+ class CheckmkError(Exception):
48
+ """Raised when a Checkmk site cannot be queried."""
49
+
50
+
51
+ # Unified severity, used to pick the "worst" state across hosts and services.
52
+ SEVERITY_OK = 0
53
+ SEVERITY_PENDING = 1
54
+ SEVERITY_WARN = 2
55
+ SEVERITY_UNKNOWN = 3
56
+ SEVERITY_CRIT = 4
57
+
58
+ HOST_STATES = {0: ("UP", SEVERITY_OK), 1: ("DOWN", SEVERITY_CRIT), 2: ("UNREACH", SEVERITY_UNKNOWN)}
59
+ SERVICE_STATES = {
60
+ 0: ("OK", SEVERITY_OK),
61
+ 1: ("WARN", SEVERITY_WARN),
62
+ 2: ("CRIT", SEVERITY_CRIT),
63
+ 3: ("UNKNOWN", SEVERITY_UNKNOWN),
64
+ }
65
+
66
+
67
+ @dataclass
68
+ class ServiceStatus:
69
+ description: str
70
+ state: int
71
+ has_been_checked: bool
72
+ plugin_output: str = ""
73
+ acknowledged: bool = False
74
+ in_downtime: bool = False
75
+
76
+ @property
77
+ def label(self) -> str:
78
+ if not self.has_been_checked:
79
+ return "PEND"
80
+ return SERVICE_STATES.get(self.state, ("UNKNOWN", SEVERITY_UNKNOWN))[0]
81
+
82
+ @property
83
+ def severity(self) -> int:
84
+ if not self.has_been_checked:
85
+ return SEVERITY_PENDING
86
+ return SERVICE_STATES.get(self.state, ("UNKNOWN", SEVERITY_UNKNOWN))[1]
87
+
88
+ @classmethod
89
+ def from_api(cls, ext: dict) -> ServiceStatus:
90
+ return cls(
91
+ description=ext.get("description", ""),
92
+ state=int(ext.get("state", 3)),
93
+ has_been_checked=bool(ext.get("has_been_checked", 1)),
94
+ plugin_output=ext.get("plugin_output", ""),
95
+ acknowledged=bool(ext.get("acknowledged", 0)),
96
+ in_downtime=bool(ext.get("scheduled_downtime_depth", 0)),
97
+ )
98
+
99
+
100
+ @dataclass
101
+ class HostStatus:
102
+ name: str
103
+ state: int
104
+ has_been_checked: bool
105
+ plugin_output: str = ""
106
+ acknowledged: bool = False
107
+ in_downtime: bool = False
108
+ last_check: int = 0
109
+ services: dict = field(default_factory=dict) # label -> count
110
+
111
+ @property
112
+ def label(self) -> str:
113
+ if not self.has_been_checked:
114
+ return "PEND"
115
+ return HOST_STATES.get(self.state, ("UNKNOWN", SEVERITY_UNKNOWN))[0]
116
+
117
+ @property
118
+ def host_severity(self) -> int:
119
+ if not self.has_been_checked:
120
+ return SEVERITY_PENDING
121
+ return HOST_STATES.get(self.state, ("UNKNOWN", SEVERITY_UNKNOWN))[1]
122
+
123
+ @property
124
+ def worst_service_severity(self) -> int:
125
+ for label, severity in (
126
+ ("CRIT", SEVERITY_CRIT),
127
+ ("UNKNOWN", SEVERITY_UNKNOWN),
128
+ ("WARN", SEVERITY_WARN),
129
+ ("PEND", SEVERITY_PENDING),
130
+ ):
131
+ if self.services.get(label):
132
+ return severity
133
+ return SEVERITY_OK
134
+
135
+ @property
136
+ def severity(self) -> int:
137
+ """Overall severity: worst of host state and service states."""
138
+ return max(self.host_severity, self.worst_service_severity)
139
+
140
+ @classmethod
141
+ def from_api(cls, ext: dict) -> HostStatus:
142
+ return cls(
143
+ name=ext["name"],
144
+ state=int(ext.get("state", 0)),
145
+ has_been_checked=bool(ext.get("has_been_checked", 1)),
146
+ plugin_output=ext.get("plugin_output", ""),
147
+ acknowledged=bool(ext.get("acknowledged", 0)),
148
+ in_downtime=bool(ext.get("scheduled_downtime_depth", 0)),
149
+ last_check=int(ext.get("last_check", 0) or 0),
150
+ services={
151
+ "OK": int(ext.get("num_services_ok", 0)),
152
+ "WARN": int(ext.get("num_services_warn", 0)),
153
+ "CRIT": int(ext.get("num_services_crit", 0)),
154
+ "UNKNOWN": int(ext.get("num_services_unknown", 0)),
155
+ "PEND": int(ext.get("num_services_pending", 0)),
156
+ },
157
+ )
158
+
159
+ def to_dict(self) -> dict:
160
+ return {
161
+ "name": self.name,
162
+ "state": self.state,
163
+ "has_been_checked": self.has_been_checked,
164
+ "plugin_output": self.plugin_output,
165
+ "acknowledged": self.acknowledged,
166
+ "in_downtime": self.in_downtime,
167
+ "last_check": self.last_check,
168
+ "services": dict(self.services),
169
+ }
170
+
171
+ @classmethod
172
+ def from_dict(cls, data: dict) -> HostStatus:
173
+ return cls(**data)
174
+
175
+
176
+ class CheckmkSite:
177
+ """One Checkmk site, addressed by its site URL, e.g. https://cmk.example.com/mysite."""
178
+
179
+ def __init__(
180
+ self,
181
+ name: str,
182
+ url: str,
183
+ username: str,
184
+ secret: str,
185
+ verify_ssl: bool = True,
186
+ timeout: float = 5,
187
+ session: requests.Session | None = None,
188
+ ):
189
+ self.name = name
190
+ self.url = url.rstrip("/")
191
+ self.api_url = f"{self.url}/check_mk/api/1.0"
192
+ self.verify_ssl = verify_ssl
193
+ self.timeout = timeout
194
+ self.session = session or requests.Session()
195
+ self.session.headers.update(
196
+ {"Authorization": f"Bearer {username} {secret}", "Accept": "application/json"}
197
+ )
198
+
199
+ @classmethod
200
+ def from_config(cls, config: dict) -> CheckmkSite:
201
+ try:
202
+ return cls(
203
+ name=config.get("name") or config["url"],
204
+ url=config["url"],
205
+ username=config["username"],
206
+ secret=config["secret"],
207
+ verify_ssl=config.get("verify_ssl", True),
208
+ timeout=config.get("timeout", 5),
209
+ )
210
+ except KeyError as e:
211
+ raise CheckmkError(f"Checkmk site config is missing {e}") from e
212
+
213
+ def _query(self, path: str, columns: tuple[str, ...], query: dict) -> dict:
214
+ # POST variant of the monitoring collection endpoints: works on 2.5 and is the
215
+ # only one left on 3.0 (the GET variant returns 405 there).
216
+ try:
217
+ resp = self.session.post(
218
+ f"{self.api_url}{path}",
219
+ json={"columns": list(columns), "query": query},
220
+ verify=self.verify_ssl,
221
+ timeout=self.timeout,
222
+ )
223
+ except requests.RequestException as e:
224
+ raise CheckmkError(f"{self.name}: {e.__class__.__name__}: {e}") from e
225
+ if resp.status_code != 200:
226
+ try:
227
+ detail = resp.json().get("detail") or resp.json().get("title")
228
+ except ValueError:
229
+ detail = resp.text[:200]
230
+ raise CheckmkError(f"{self.name}: HTTP {resp.status_code}: {detail}")
231
+ try:
232
+ return resp.json()
233
+ except ValueError as e:
234
+ raise CheckmkError(f"{self.name}: invalid JSON response") from e
235
+
236
+ def get_hosts(self, names: list[str]) -> dict[str, HostStatus]:
237
+ """Look up hosts by name (case-insensitive).
238
+
239
+ Returns a dict keyed by the *requested* name (lowercased) for every host found.
240
+ """
241
+ found: dict[str, HostStatus] = {}
242
+ wanted = sorted({n.lower() for n in names if n})
243
+ for i in range(0, len(wanted), QUERY_CHUNK):
244
+ chunk = wanted[i : i + QUERY_CHUNK]
245
+ query = {
246
+ "op": "or",
247
+ "expr": [{"op": "~~", "left": "name", "right": f"^{_regex_literal(n)}$"} for n in chunk],
248
+ }
249
+ data = self._query("/domain-types/host/collections/all", HOST_COLUMNS, query)
250
+ for item in data.get("value", []):
251
+ host = HostStatus.from_api(item.get("extensions", {}))
252
+ found.setdefault(host.name.lower(), host)
253
+ return found
254
+
255
+ def get_services(self, host_name: str, limit: int = 500) -> list[ServiceStatus]:
256
+ """All services of a host, worst state first."""
257
+ query = {"op": "=", "left": "host_name", "right": host_name}
258
+ data = self._query("/domain-types/service/collections/all", SERVICE_COLUMNS, query)
259
+ services = [ServiceStatus.from_api(i.get("extensions", {})) for i in data.get("value", [])]
260
+ services.sort(key=lambda s: (-s.severity, s.description.lower()))
261
+ return services[:limit]
262
+
263
+ def _view_url(self, **params: str) -> str:
264
+ return f"{self.url}/check_mk/index.py?" + urlencode(
265
+ {"start_url": "view.py?" + urlencode(params)}, quote_via=quote
266
+ )
267
+
268
+ def host_url(self, host_name: str) -> str:
269
+ return self._view_url(view_name="host", host=host_name)
270
+
271
+ def service_url(self, host_name: str, service: str) -> str:
272
+ return self._view_url(view_name="service", host=host_name, service=service)
@@ -0,0 +1,100 @@
1
+ # German translation of the netbox-checkmk NetBox plugin.
2
+ # Compile with: python scripts/compile_translations.py
3
+ msgid ""
4
+ msgstr ""
5
+ "Project-Id-Version: netbox-checkmk\n"
6
+ "Language: de\n"
7
+ "MIME-Version: 1.0\n"
8
+ "Content-Type: text/plain; charset=UTF-8\n"
9
+ "Content-Transfer-Encoding: 8bit\n"
10
+ "Plural-Forms: nplurals=2; plural=(n != 1);\n"
11
+
12
+ msgid "Checkmk"
13
+ msgstr "Checkmk"
14
+
15
+ msgid "Checkmk Monitoring"
16
+ msgstr "Checkmk-Monitoring"
17
+
18
+ msgid "Refresh"
19
+ msgstr "Aktualisieren"
20
+
21
+ msgid "Reload from Checkmk (bypass cache)"
22
+ msgstr "Neu aus Checkmk laden (Cache umgehen)"
23
+
24
+ msgid "Loading Checkmk status…"
25
+ msgstr "Checkmk-Status wird geladen …"
26
+
27
+ msgid "Checkmk query failed"
28
+ msgstr "Checkmk-Abfrage fehlgeschlagen"
29
+
30
+ msgid "No Checkmk sites configured. Set PLUGINS_CONFIG['netbox_checkmk']['sites']."
31
+ msgstr "Keine Checkmk-Sites konfiguriert. Bitte PLUGINS_CONFIG['netbox_checkmk']['sites'] setzen."
32
+
33
+ msgid "Overall state"
34
+ msgstr "Gesamtzustand"
35
+
36
+ msgid "%(found)s of %(total)s members found in Checkmk"
37
+ msgstr "%(found)s von %(total)s Mitgliedern in Checkmk gefunden"
38
+
39
+ msgid "Cluster host"
40
+ msgstr "Cluster-Host"
41
+
42
+ msgid "No Checkmk host named \"%(name)s\""
43
+ msgstr "Kein Checkmk-Host namens \"%(name)s\""
44
+
45
+ msgid "Member"
46
+ msgstr "Mitglied"
47
+
48
+ msgid "Checkmk host"
49
+ msgstr "Checkmk-Host"
50
+
51
+ msgid "Host"
52
+ msgstr "Host"
53
+
54
+ msgid "Services"
55
+ msgstr "Services"
56
+
57
+ msgid "Site"
58
+ msgstr "Site"
59
+
60
+ msgid "Not found in Checkmk"
61
+ msgstr "In Checkmk nicht gefunden"
62
+
63
+ msgid "This cluster has no member devices or virtual machines."
64
+ msgstr "Dieser Cluster enthält keine Geräte oder virtuellen Maschinen."
65
+
66
+ msgid "Member list truncated (max_cluster_members)."
67
+ msgstr "Mitgliederliste gekürzt (max_cluster_members)."
68
+
69
+ msgid "ACK"
70
+ msgstr "ACK"
71
+
72
+ msgid "Downtime"
73
+ msgstr "Wartungszeit"
74
+
75
+ msgid "Output"
76
+ msgstr "Ausgabe"
77
+
78
+ msgid "Last check"
79
+ msgstr "Letzter Check"
80
+
81
+ msgid "State"
82
+ msgstr "Zustand"
83
+
84
+ msgid "Service"
85
+ msgstr "Service"
86
+
87
+ msgid "Acknowledged"
88
+ msgstr "Quittiert"
89
+
90
+ msgid "Scheduled downtime"
91
+ msgstr "Geplante Wartungszeit"
92
+
93
+ msgid "Host \"%(name)s\" could not be determined."
94
+ msgstr "Host \"%(name)s\" konnte nicht ermittelt werden."
95
+
96
+ msgid "No Checkmk host named \"%(name)s\" found on any configured site."
97
+ msgstr "Auf keiner konfigurierten Site wurde ein Checkmk-Host namens \"%(name)s\" gefunden."
98
+
99
+ msgid "This object has no name to look up in Checkmk."
100
+ msgstr "Dieses Objekt hat keinen Namen, nach dem in Checkmk gesucht werden kann."
@@ -0,0 +1,34 @@
1
+ """Creates the host name override custom field on `manage.py migrate` (if enabled)."""
2
+
3
+ from django.apps import apps
4
+ from django.db.models.signals import post_migrate
5
+
6
+ from .status import config
7
+
8
+
9
+ def create_custom_field(**kwargs):
10
+ from core.models import ObjectType
11
+ from dcim.models import Device
12
+ from extras.choices import CustomFieldTypeChoices
13
+ from extras.models import CustomField
14
+ from virtualization.models import Cluster, VirtualMachine
15
+
16
+ name = config("hostname_custom_field")
17
+ if not name or not config("create_custom_field") or CustomField.objects.filter(name=name).exists():
18
+ return
19
+ field = CustomField.objects.create(
20
+ name=name,
21
+ type=CustomFieldTypeChoices.TYPE_TEXT,
22
+ label="Checkmk host name",
23
+ description="Checkmk host name, if it differs from this object's name (netbox-checkmk plugin)",
24
+ required=False,
25
+ )
26
+ field.object_types.set([ObjectType.objects.get_for_model(m) for m in (Device, VirtualMachine, Cluster)])
27
+ print(f"netbox_checkmk: created custom field '{name}'")
28
+
29
+
30
+ # post_migrate is only sent for apps with models; hook onto NetBox's "extras" app, which
31
+ # owns CustomField. It fires after the whole migrate run, so all tables exist by then.
32
+ post_migrate.connect(
33
+ create_custom_field, sender=apps.get_app_config("extras"), dispatch_uid="netbox_checkmk_custom_field"
34
+ )
@@ -0,0 +1,123 @@
1
+ """Hostname resolution, multi-site lookup and caching on top of the Checkmk client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from django.core.cache import cache
8
+ from netbox.plugins import get_plugin_config
9
+
10
+ from .checkmk import CheckmkError, CheckmkSite, HostStatus, ServiceStatus
11
+
12
+ PLUGIN_NAME = "netbox_checkmk"
13
+ CACHE_PREFIX = "netbox_checkmk:v1"
14
+ _NOT_FOUND = "__not_found__"
15
+
16
+
17
+ def config(key: str):
18
+ return get_plugin_config(PLUGIN_NAME, key)
19
+
20
+
21
+ def get_sites() -> list[CheckmkSite]:
22
+ return [CheckmkSite.from_config(c) for c in config("sites") or []]
23
+
24
+
25
+ def checkmk_hostname(obj) -> str:
26
+ """Checkmk host name for a NetBox object: custom field override, else the object name."""
27
+ cf_name = config("hostname_custom_field")
28
+ if cf_name:
29
+ override = (getattr(obj, "custom_field_data", None) or {}).get(cf_name)
30
+ if override:
31
+ return str(override).strip()
32
+ return (getattr(obj, "name", None) or "").strip()
33
+
34
+
35
+ @dataclass
36
+ class HostResult:
37
+ hostname: str
38
+ site: CheckmkSite | None = None
39
+ status: HostStatus | None = None
40
+
41
+ @property
42
+ def found(self) -> bool:
43
+ return self.status is not None
44
+
45
+ @property
46
+ def url(self) -> str | None:
47
+ return self.site.host_url(self.status.name) if self.found else None
48
+
49
+
50
+ @dataclass
51
+ class LookupResult:
52
+ hosts: dict[str, HostResult] = field(default_factory=dict) # keyed by lowercased hostname
53
+ errors: list[str] = field(default_factory=list)
54
+
55
+ def get(self, hostname: str) -> HostResult:
56
+ return self.hosts.get(hostname.lower()) or HostResult(hostname=hostname)
57
+
58
+
59
+ def _cache_key(kind: str, *parts: str) -> str:
60
+ # Hex-encode: memcached-style backends reject spaces/control characters in keys.
61
+ return ":".join([CACHE_PREFIX, kind] + [p.lower().encode("utf-8").hex() for p in parts])
62
+
63
+
64
+ def lookup_hosts(hostnames: list[str], refresh: bool = False) -> LookupResult:
65
+ """Find hosts across all configured sites (first site that knows a host wins).
66
+
67
+ Results (including "not found") are cached for ``check_interval`` seconds. If any
68
+ site errors, hosts that could not be found are reported as unknown and not cached,
69
+ since they may well live on the failing site.
70
+ """
71
+ result = LookupResult()
72
+ sites = {s.name: s for s in get_sites()}
73
+ timeout = config("check_interval")
74
+ pending = []
75
+ for name in dict.fromkeys(h for h in hostnames if h):
76
+ key = name.lower()
77
+ cached = None if refresh else cache.get(_cache_key("host", name))
78
+ if cached == _NOT_FOUND:
79
+ result.hosts[key] = HostResult(hostname=name)
80
+ elif cached and cached["site"] in sites:
81
+ result.hosts[key] = HostResult(
82
+ hostname=name, site=sites[cached["site"]], status=HostStatus.from_dict(cached["status"])
83
+ )
84
+ else:
85
+ pending.append(name)
86
+
87
+ for site in sites.values():
88
+ if not pending:
89
+ break
90
+ try:
91
+ found = site.get_hosts(pending)
92
+ except CheckmkError as e:
93
+ result.errors.append(str(e))
94
+ continue
95
+ still_pending = []
96
+ for name in pending:
97
+ status = found.get(name.lower())
98
+ if status is None:
99
+ still_pending.append(name)
100
+ continue
101
+ result.hosts[name.lower()] = HostResult(hostname=name, site=site, status=status)
102
+ cache.set(_cache_key("host", name), {"site": site.name, "status": status.to_dict()}, timeout)
103
+ pending = still_pending
104
+
105
+ for name in pending:
106
+ result.hosts[name.lower()] = HostResult(hostname=name)
107
+ if not result.errors:
108
+ cache.set(_cache_key("host", name), _NOT_FOUND, timeout)
109
+ return result
110
+
111
+
112
+ def host_services(host: HostResult, refresh: bool = False):
113
+ """All services of a found host (cached). Returns (services, error)."""
114
+ key = _cache_key("svc", host.site.name, host.status.name)
115
+ cached = None if refresh else cache.get(key)
116
+ if cached is not None:
117
+ return [ServiceStatus(**s) for s in cached], None
118
+ try:
119
+ services = host.site.get_services(host.status.name, config("max_services"))
120
+ except CheckmkError as e:
121
+ return [], str(e)
122
+ cache.set(key, [s.__dict__ for s in services], config("check_interval"))
123
+ return services, None
@@ -0,0 +1,34 @@
1
+ """Optional "Checkmk" column for the core device, VM and cluster lists.
2
+
3
+ Registered via NetBox's documented plugin API (`register_table_column`). Hidden by default;
4
+ users enable it with "Configure Table". Each cell lazy-loads its badges via htmx, so a list
5
+ page never waits for Checkmk.
6
+ """
7
+
8
+ import django_tables2 as tables
9
+ from dcim.tables import DeviceTable
10
+ from django.urls import reverse
11
+ from django.utils.html import format_html
12
+ from django.utils.translation import gettext_lazy as _
13
+ from utilities.tables import register_table_column
14
+ from virtualization.tables import ClusterTable, VirtualMachineTable
15
+
16
+
17
+ class CheckmkStatusColumn(tables.Column):
18
+ def __init__(self):
19
+ super().__init__(verbose_name=_("Checkmk"), accessor=tables.A("pk"), orderable=False, empty_values=())
20
+
21
+ def render(self, record):
22
+ url = reverse(
23
+ "plugins:netbox_checkmk:tab_badges",
24
+ kwargs={"model": record._meta.model_name, "pk": record.pk},
25
+ )
26
+ return format_html('<span hx-get="{}?list=1" hx-trigger="load"></span>', url)
27
+
28
+ def value(self, record):
29
+ # CSV/export: live state isn't part of the exported data.
30
+ return ""
31
+
32
+
33
+ for _table in (DeviceTable, VirtualMachineTable, ClusterTable):
34
+ register_table_column(CheckmkStatusColumn(), "checkmk_status", _table)