netbox-loki 0.0.1__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.
- netbox_loki/__init__.py +116 -0
- netbox_loki/forms.py +174 -0
- netbox_loki/loki_client.py +377 -0
- netbox_loki/migrations/0001_initial.py +20 -0
- netbox_loki/migrations/0002_rename_model.py +15 -0
- netbox_loki/migrations/0003_mark_stub_model_private.py +34 -0
- netbox_loki/migrations/__init__.py +0 -0
- netbox_loki/models.py +13 -0
- netbox_loki/navigation.py +22 -0
- netbox_loki/templates/netbox_loki/device_logs_tab.html +20 -0
- netbox_loki/templates/netbox_loki/endpoint_logs_tab.html +20 -0
- netbox_loki/templates/netbox_loki/logs_tab.html +5 -0
- netbox_loki/templates/netbox_loki/logs_tab_content.html +103 -0
- netbox_loki/templates/netbox_loki/settings.html +227 -0
- netbox_loki/templates/netbox_loki/vm_logs_tab.html +20 -0
- netbox_loki/templates/netbox_loki/widgets/loki_summary.html +50 -0
- netbox_loki/urls.py +21 -0
- netbox_loki/views.py +214 -0
- netbox_loki/widgets.py +82 -0
- netbox_loki-0.0.1.dist-info/METADATA +159 -0
- netbox_loki-0.0.1.dist-info/RECORD +24 -0
- netbox_loki-0.0.1.dist-info/WHEEL +5 -0
- netbox_loki-0.0.1.dist-info/licenses/LICENSE +190 -0
- netbox_loki-0.0.1.dist-info/top_level.txt +1 -0
netbox_loki/__init__.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NetBox Loki plugin.
|
|
3
|
+
|
|
4
|
+
Display recent Loki logs in Device and VirtualMachine detail pages.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from netbox.plugins import PluginConfig
|
|
10
|
+
|
|
11
|
+
__version__ = "1.2.0"
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LokiConfig(PluginConfig):
|
|
17
|
+
"""Plugin configuration for NetBox Loki integration."""
|
|
18
|
+
|
|
19
|
+
name = "netbox_loki"
|
|
20
|
+
verbose_name = "NetBox Loki Logs"
|
|
21
|
+
description = "Display recent Loki logs in device, VM, and endpoint detail pages"
|
|
22
|
+
version = __version__
|
|
23
|
+
author = "-----"
|
|
24
|
+
author_email = "---@gmail.com"
|
|
25
|
+
base_url = "loki"
|
|
26
|
+
min_version = "4.0.0"
|
|
27
|
+
max_version = "5.99"
|
|
28
|
+
|
|
29
|
+
# Required settings - plugin won't load without these
|
|
30
|
+
required_settings = []
|
|
31
|
+
|
|
32
|
+
# Default configuration values
|
|
33
|
+
default_settings = {
|
|
34
|
+
"loki_url": "http://localhost:3100",
|
|
35
|
+
"loki_external_url": "",
|
|
36
|
+
"loki_tenant": "docker",
|
|
37
|
+
"loki_username": "",
|
|
38
|
+
"loki_password": "",
|
|
39
|
+
"loki_bearer_token": "",
|
|
40
|
+
"loki_job": "syslog",
|
|
41
|
+
"device_label": "routerboard",
|
|
42
|
+
"stream_selector": "",
|
|
43
|
+
"log_limit": 100,
|
|
44
|
+
"time_range": 3600,
|
|
45
|
+
"timeout": 10,
|
|
46
|
+
"cache_timeout": 60,
|
|
47
|
+
"verify_tls": True,
|
|
48
|
+
"use_regex_matching": True,
|
|
49
|
+
"fallback_to_ip": True,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def ready(self):
|
|
53
|
+
"""Register endpoint view if netbox_endpoints is available."""
|
|
54
|
+
super().ready()
|
|
55
|
+
from . import widgets # noqa: F401
|
|
56
|
+
|
|
57
|
+
self._register_endpoint_views()
|
|
58
|
+
|
|
59
|
+
def _register_endpoint_views(self):
|
|
60
|
+
"""Register Loki Logs tab for Endpoints if plugin is installed."""
|
|
61
|
+
import sys
|
|
62
|
+
|
|
63
|
+
# Quick check if netbox_endpoints is available
|
|
64
|
+
if "netbox_endpoints" not in sys.modules:
|
|
65
|
+
try:
|
|
66
|
+
import importlib.util
|
|
67
|
+
|
|
68
|
+
if importlib.util.find_spec("netbox_endpoints") is None:
|
|
69
|
+
logger.debug("netbox_endpoints not installed, skipping endpoint view registration")
|
|
70
|
+
return
|
|
71
|
+
except Exception:
|
|
72
|
+
logger.debug("netbox_endpoints not available, skipping endpoint view registration")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
from django.shortcuts import render
|
|
77
|
+
from netbox.views import generic
|
|
78
|
+
from netbox_endpoints.models import Endpoint
|
|
79
|
+
from utilities.views import ViewTab, register_model_view
|
|
80
|
+
|
|
81
|
+
@register_model_view(Endpoint, name="loki_logs", path="logs")
|
|
82
|
+
class EndpointLokiLogsView(generic.ObjectView):
|
|
83
|
+
"""Display Loki logs for an Endpoint with async loading."""
|
|
84
|
+
|
|
85
|
+
queryset = Endpoint.objects.all()
|
|
86
|
+
template_name = "netbox_loki/endpoint_logs_tab.html"
|
|
87
|
+
|
|
88
|
+
tab = ViewTab(
|
|
89
|
+
label="Loki",
|
|
90
|
+
weight=9004,
|
|
91
|
+
permission="netbox_endpoints.view_endpoint",
|
|
92
|
+
hide_if_empty=False,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
def get(self, request, pk):
|
|
96
|
+
endpoint = Endpoint.objects.get(pk=pk)
|
|
97
|
+
time_range = request.GET.get("range", "")
|
|
98
|
+
return render(
|
|
99
|
+
request,
|
|
100
|
+
self.template_name,
|
|
101
|
+
{
|
|
102
|
+
"object": endpoint,
|
|
103
|
+
"tab": self.tab,
|
|
104
|
+
"loading": True,
|
|
105
|
+
"time_range_param": time_range,
|
|
106
|
+
},
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
logger.info("Registered Loki Logs tab for Endpoint model")
|
|
110
|
+
except ImportError:
|
|
111
|
+
logger.debug("netbox_endpoints not installed, skipping endpoint view registration")
|
|
112
|
+
except Exception as e:
|
|
113
|
+
logger.warning(f"Could not register endpoint views: {e}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
config = LokiConfig
|
netbox_loki/forms.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Forms for NetBox Loki plugin settings.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from django import forms
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class LokiSettingsForm(forms.Form):
|
|
9
|
+
"""Form for displaying Loki plugin settings."""
|
|
10
|
+
|
|
11
|
+
loki_url = forms.URLField(
|
|
12
|
+
label="Loki URL",
|
|
13
|
+
help_text="Base URL for the Loki HTTP API, for example: http://loki:3100",
|
|
14
|
+
required=True,
|
|
15
|
+
widget=forms.URLInput(
|
|
16
|
+
attrs={
|
|
17
|
+
"class": "form-control",
|
|
18
|
+
"placeholder": "http://loki:3100",
|
|
19
|
+
}
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
loki_external_url = forms.URLField(
|
|
24
|
+
label="External URL",
|
|
25
|
+
help_text="Optional browser URL for opening Loki or Grafana from the NetBox UI.",
|
|
26
|
+
required=False,
|
|
27
|
+
widget=forms.URLInput(
|
|
28
|
+
attrs={
|
|
29
|
+
"class": "form-control",
|
|
30
|
+
"placeholder": "https://grafana.example.com/explore",
|
|
31
|
+
}
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
loki_tenant = forms.CharField(
|
|
36
|
+
label="Tenant Header",
|
|
37
|
+
help_text="Optional value for the X-Scope-OrgID header when Loki multi-tenancy is enabled.",
|
|
38
|
+
required=False,
|
|
39
|
+
initial="docker",
|
|
40
|
+
widget=forms.TextInput(
|
|
41
|
+
attrs={
|
|
42
|
+
"class": "form-control",
|
|
43
|
+
"placeholder": "docker",
|
|
44
|
+
}
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
loki_username = forms.CharField(
|
|
49
|
+
label="Username",
|
|
50
|
+
help_text="Optional basic-auth username used by a reverse proxy or managed Loki.",
|
|
51
|
+
required=False,
|
|
52
|
+
widget=forms.TextInput(attrs={"class": "form-control"}),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
loki_password = forms.CharField(
|
|
56
|
+
label="Password",
|
|
57
|
+
help_text="Optional basic-auth password.",
|
|
58
|
+
required=False,
|
|
59
|
+
widget=forms.PasswordInput(attrs={"class": "form-control"}, render_value=True),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
loki_bearer_token = forms.CharField(
|
|
63
|
+
label="Bearer Token",
|
|
64
|
+
help_text="Optional bearer token if your Loki endpoint is protected by token auth.",
|
|
65
|
+
required=False,
|
|
66
|
+
widget=forms.PasswordInput(attrs={"class": "form-control"}, render_value=True),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
loki_job = forms.CharField(
|
|
70
|
+
label="Job Label Value",
|
|
71
|
+
help_text="Optional value for the standard Loki label `job`, for example: `syslog`.",
|
|
72
|
+
required=False,
|
|
73
|
+
initial="syslog",
|
|
74
|
+
widget=forms.TextInput(
|
|
75
|
+
attrs={
|
|
76
|
+
"class": "form-control",
|
|
77
|
+
"placeholder": "syslog",
|
|
78
|
+
}
|
|
79
|
+
),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
device_label = forms.CharField(
|
|
83
|
+
label="Device Label",
|
|
84
|
+
help_text="Loki label that stores the device or VM name, for example: `routerboard`, `host`, or `hostname`.",
|
|
85
|
+
required=True,
|
|
86
|
+
initial="routerboard",
|
|
87
|
+
widget=forms.TextInput(
|
|
88
|
+
attrs={
|
|
89
|
+
"class": "form-control",
|
|
90
|
+
"placeholder": "routerboard",
|
|
91
|
+
}
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
stream_selector = forms.CharField(
|
|
96
|
+
label="Extra Stream Selector",
|
|
97
|
+
help_text='Optional extra Loki label selector fragment without braces, for example: `site="kyiv",env="prod"`.',
|
|
98
|
+
required=False,
|
|
99
|
+
widget=forms.TextInput(
|
|
100
|
+
attrs={
|
|
101
|
+
"class": "form-control",
|
|
102
|
+
"placeholder": 'site="kyiv",env="prod"',
|
|
103
|
+
}
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
log_limit = forms.IntegerField(
|
|
108
|
+
label="Log Limit",
|
|
109
|
+
help_text="Maximum number of log lines to display per request.",
|
|
110
|
+
required=False,
|
|
111
|
+
initial=100,
|
|
112
|
+
min_value=10,
|
|
113
|
+
max_value=500,
|
|
114
|
+
widget=forms.NumberInput(attrs={"class": "form-control"}),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
time_range = forms.ChoiceField(
|
|
118
|
+
label="Default Time Range",
|
|
119
|
+
help_text="Default time range for log queries.",
|
|
120
|
+
choices=[
|
|
121
|
+
(300, "5 minutes"),
|
|
122
|
+
(900, "15 minutes"),
|
|
123
|
+
(3600, "1 hour"),
|
|
124
|
+
(14400, "4 hours"),
|
|
125
|
+
(86400, "24 hours"),
|
|
126
|
+
(604800, "7 days"),
|
|
127
|
+
],
|
|
128
|
+
initial=3600,
|
|
129
|
+
widget=forms.Select(attrs={"class": "form-control"}),
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
timeout = forms.IntegerField(
|
|
133
|
+
label="API Timeout",
|
|
134
|
+
help_text="Timeout for Loki API requests in seconds.",
|
|
135
|
+
required=False,
|
|
136
|
+
initial=10,
|
|
137
|
+
min_value=5,
|
|
138
|
+
max_value=60,
|
|
139
|
+
widget=forms.NumberInput(attrs={"class": "form-control"}),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
cache_timeout = forms.IntegerField(
|
|
143
|
+
label="Cache Timeout",
|
|
144
|
+
help_text="How long to cache API responses in seconds.",
|
|
145
|
+
required=False,
|
|
146
|
+
initial=60,
|
|
147
|
+
min_value=0,
|
|
148
|
+
max_value=300,
|
|
149
|
+
widget=forms.NumberInput(attrs={"class": "form-control"}),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
verify_tls = forms.BooleanField(
|
|
153
|
+
label="Verify TLS Certificates",
|
|
154
|
+
help_text="Disable only if your Loki endpoint uses a self-signed certificate.",
|
|
155
|
+
required=False,
|
|
156
|
+
initial=True,
|
|
157
|
+
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
use_regex_matching = forms.BooleanField(
|
|
161
|
+
label="Allow Shortname/FQDN Matching",
|
|
162
|
+
help_text="Match `router1` against both `router1` and `router1.example.com` in Loki labels.",
|
|
163
|
+
required=False,
|
|
164
|
+
initial=True,
|
|
165
|
+
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
fallback_to_ip = forms.BooleanField(
|
|
169
|
+
label="Fallback To Primary IP",
|
|
170
|
+
help_text="If hostname lookup returns no logs, try the primary IP from NetBox.",
|
|
171
|
+
required=False,
|
|
172
|
+
initial=True,
|
|
173
|
+
widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
|
|
174
|
+
)
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Loki API client.
|
|
3
|
+
|
|
4
|
+
The module name stays the same to preserve import compatibility with the
|
|
5
|
+
existing plugin package layout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from datetime import datetime, timedelta, timezone
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
from django.conf import settings
|
|
17
|
+
from django.core.cache import cache
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _read_netbox_setting(config: dict[str, Any], plugin_key: str, setting_names: tuple[str, ...], default: Any) -> Any:
|
|
23
|
+
"""Read a plugin setting from PLUGINS_CONFIG first, then from top-level Django settings."""
|
|
24
|
+
if plugin_key in config:
|
|
25
|
+
return config[plugin_key]
|
|
26
|
+
|
|
27
|
+
for setting_name in setting_names:
|
|
28
|
+
if hasattr(settings, setting_name):
|
|
29
|
+
return getattr(settings, setting_name)
|
|
30
|
+
|
|
31
|
+
return default
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class LokiClient:
|
|
35
|
+
"""Client for querying Grafana Loki."""
|
|
36
|
+
|
|
37
|
+
def __init__(self) -> None:
|
|
38
|
+
self.config = settings.PLUGINS_CONFIG.get("netbox_loki", {})
|
|
39
|
+
self.base_url = _read_netbox_setting(
|
|
40
|
+
self.config,
|
|
41
|
+
"loki_url",
|
|
42
|
+
("NETBOX_LOKI_LOKI_URL", "LOKI_URL"),
|
|
43
|
+
"http://localhost:3100",
|
|
44
|
+
).rstrip("/")
|
|
45
|
+
self.timeout = _read_netbox_setting(self.config, "timeout", ("NETBOX_LOKI_TIMEOUT",), 10)
|
|
46
|
+
self.cache_timeout = _read_netbox_setting(self.config, "cache_timeout", ("NETBOX_LOKI_CACHE_TIMEOUT",), 60)
|
|
47
|
+
self.verify_tls = _read_netbox_setting(self.config, "verify_tls", ("NETBOX_LOKI_VERIFY_TLS",), True)
|
|
48
|
+
|
|
49
|
+
def _get_headers(self) -> dict[str, str]:
|
|
50
|
+
headers = {
|
|
51
|
+
"Accept": "application/json",
|
|
52
|
+
"X-Requested-By": "NetBox-Loki-Plugin",
|
|
53
|
+
}
|
|
54
|
+
tenant = str(
|
|
55
|
+
_read_netbox_setting(
|
|
56
|
+
self.config,
|
|
57
|
+
"loki_tenant",
|
|
58
|
+
("NETBOX_LOKI_TENANT", "LOKI_TENANT", "LOKI_X_SCOPE_ORGID"),
|
|
59
|
+
"docker",
|
|
60
|
+
)
|
|
61
|
+
).strip()
|
|
62
|
+
bearer = str(
|
|
63
|
+
_read_netbox_setting(
|
|
64
|
+
self.config,
|
|
65
|
+
"loki_bearer_token",
|
|
66
|
+
("NETBOX_LOKI_BEARER_TOKEN", "LOKI_BEARER_TOKEN"),
|
|
67
|
+
"",
|
|
68
|
+
)
|
|
69
|
+
).strip()
|
|
70
|
+
if tenant:
|
|
71
|
+
headers["X-Scope-OrgID"] = tenant
|
|
72
|
+
if bearer:
|
|
73
|
+
headers["Authorization"] = f"Bearer {bearer}"
|
|
74
|
+
return headers
|
|
75
|
+
|
|
76
|
+
def _get_auth(self) -> tuple[str, str] | None:
|
|
77
|
+
username = str(
|
|
78
|
+
_read_netbox_setting(
|
|
79
|
+
self.config,
|
|
80
|
+
"loki_username",
|
|
81
|
+
("NETBOX_LOKI_USERNAME", "LOKI_USERNAME"),
|
|
82
|
+
"",
|
|
83
|
+
)
|
|
84
|
+
).strip()
|
|
85
|
+
password = _read_netbox_setting(
|
|
86
|
+
self.config,
|
|
87
|
+
"loki_password",
|
|
88
|
+
("NETBOX_LOKI_PASSWORD", "LOKI_PASSWORD"),
|
|
89
|
+
"",
|
|
90
|
+
)
|
|
91
|
+
if username:
|
|
92
|
+
return (username, password)
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def _request(
|
|
96
|
+
self,
|
|
97
|
+
*,
|
|
98
|
+
endpoint: str,
|
|
99
|
+
params: dict[str, Any],
|
|
100
|
+
) -> dict[str, Any]:
|
|
101
|
+
response = requests.get(
|
|
102
|
+
f"{self.base_url}{endpoint}",
|
|
103
|
+
params=params,
|
|
104
|
+
headers=self._get_headers(),
|
|
105
|
+
auth=self._get_auth(),
|
|
106
|
+
timeout=self.timeout,
|
|
107
|
+
verify=self.verify_tls,
|
|
108
|
+
)
|
|
109
|
+
response.raise_for_status()
|
|
110
|
+
return response.json()
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _ns_timestamp(value: datetime) -> str:
|
|
114
|
+
return str(int(value.timestamp() * 1_000_000_000))
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _format_timestamp(raw_value: str) -> str:
|
|
118
|
+
try:
|
|
119
|
+
as_int = int(raw_value)
|
|
120
|
+
return datetime.fromtimestamp(as_int / 1_000_000_000, tz=timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
121
|
+
except (TypeError, ValueError, OSError):
|
|
122
|
+
return raw_value
|
|
123
|
+
|
|
124
|
+
def _default_selector_parts(self) -> list[str]:
|
|
125
|
+
parts: list[str] = []
|
|
126
|
+
|
|
127
|
+
job = self.config.get("loki_job", "").strip()
|
|
128
|
+
if job:
|
|
129
|
+
parts.append(f'job="{self._escape_label_value(job)}"')
|
|
130
|
+
|
|
131
|
+
extra_selector = self.config.get("stream_selector", "").strip()
|
|
132
|
+
if extra_selector:
|
|
133
|
+
parts.append(extra_selector)
|
|
134
|
+
|
|
135
|
+
return parts
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _escape_label_value(value: str) -> str:
|
|
139
|
+
return value.replace("\\", "\\\\").replace('"', '\\"')
|
|
140
|
+
|
|
141
|
+
def _build_value_matcher(self, value: str) -> tuple[str, str]:
|
|
142
|
+
if self.config.get("use_regex_matching", True):
|
|
143
|
+
if "." in value:
|
|
144
|
+
return "=~", self._escape_label_value(f"^{re.escape(value)}$")
|
|
145
|
+
return "=~", self._escape_label_value(f"^{re.escape(value)}(?:\\..+)?$")
|
|
146
|
+
return "=", self._escape_label_value(value)
|
|
147
|
+
|
|
148
|
+
def _build_selector(self, *, label_name: str | None = None, value: str | None = None) -> str:
|
|
149
|
+
selector_parts = self._default_selector_parts()
|
|
150
|
+
effective_label = label_name or self.config.get("device_label", "routerboard")
|
|
151
|
+
|
|
152
|
+
if value:
|
|
153
|
+
operator, matcher = self._build_value_matcher(value)
|
|
154
|
+
selector_parts.append(f'{effective_label}{operator}"{matcher}"')
|
|
155
|
+
|
|
156
|
+
return "{" + ",".join(selector_parts) + "}"
|
|
157
|
+
|
|
158
|
+
def search_logs(
|
|
159
|
+
self,
|
|
160
|
+
value: str | None = None,
|
|
161
|
+
*,
|
|
162
|
+
time_range: int | None = None,
|
|
163
|
+
limit: int | None = None,
|
|
164
|
+
label_name: str | None = None,
|
|
165
|
+
) -> dict[str, Any]:
|
|
166
|
+
time_range = int(time_range or self.config.get("time_range", 3600))
|
|
167
|
+
limit = int(limit or self.config.get("log_limit", 100))
|
|
168
|
+
selector = self._build_selector(label_name=label_name, value=value)
|
|
169
|
+
|
|
170
|
+
cache_key = f"loki_logs::{selector}::{time_range}::{limit}"
|
|
171
|
+
cached = cache.get(cache_key)
|
|
172
|
+
if cached is not None:
|
|
173
|
+
return cached
|
|
174
|
+
|
|
175
|
+
end = datetime.now(timezone.utc)
|
|
176
|
+
start = end - timedelta(seconds=time_range)
|
|
177
|
+
query = selector
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
payload = self._request(
|
|
181
|
+
endpoint="/loki/api/v1/query_range",
|
|
182
|
+
params={
|
|
183
|
+
"query": query,
|
|
184
|
+
"start": self._ns_timestamp(start),
|
|
185
|
+
"end": self._ns_timestamp(end),
|
|
186
|
+
"limit": limit,
|
|
187
|
+
"direction": "backward",
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
result = self._parse_log_response(payload, query=query, time_range=time_range)
|
|
191
|
+
cache.set(cache_key, result, self.cache_timeout)
|
|
192
|
+
return result
|
|
193
|
+
except requests.RequestException as exc:
|
|
194
|
+
logger.warning("Loki request failed for query %s: %s", query, exc)
|
|
195
|
+
return {"error": str(exc), "messages": [], "query": query, "time_range": time_range}
|
|
196
|
+
|
|
197
|
+
def _parse_log_response(self, payload: dict[str, Any], *, query: str, time_range: int) -> dict[str, Any]:
|
|
198
|
+
messages: list[dict[str, Any]] = []
|
|
199
|
+
data = payload.get("data", {})
|
|
200
|
+
|
|
201
|
+
for stream in data.get("result", []):
|
|
202
|
+
labels = stream.get("stream", {})
|
|
203
|
+
for timestamp, line in stream.get("values", []):
|
|
204
|
+
messages.append(
|
|
205
|
+
{
|
|
206
|
+
"message": {
|
|
207
|
+
"timestamp": timestamp,
|
|
208
|
+
"timestamp_display": self._format_timestamp(timestamp),
|
|
209
|
+
"message": line,
|
|
210
|
+
"source": self._derive_source(labels),
|
|
211
|
+
"job": labels.get("job", ""),
|
|
212
|
+
"severity": self._derive_severity(labels, line),
|
|
213
|
+
"labels": labels,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
messages.sort(key=lambda item: item["message"]["timestamp"], reverse=True)
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
"messages": messages,
|
|
222
|
+
"total_results": len(messages),
|
|
223
|
+
"query": query,
|
|
224
|
+
"time_range": time_range,
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
def _derive_source(self, labels: dict[str, str]) -> str:
|
|
228
|
+
configured_label = self.config.get("device_label", "routerboard")
|
|
229
|
+
for key in (configured_label, "host", "hostname", "instance", "source"):
|
|
230
|
+
value = labels.get(key)
|
|
231
|
+
if value:
|
|
232
|
+
return value
|
|
233
|
+
return ""
|
|
234
|
+
|
|
235
|
+
@staticmethod
|
|
236
|
+
def _derive_severity(labels: dict[str, str], line: str) -> str:
|
|
237
|
+
label_severity = labels.get("severity") or labels.get("level")
|
|
238
|
+
if label_severity:
|
|
239
|
+
return str(label_severity)
|
|
240
|
+
|
|
241
|
+
match = re.search(r":(?P<severity>[A-Za-z][A-Za-z0-9_-]{1,31}):", line)
|
|
242
|
+
if match:
|
|
243
|
+
raw = match.group("severity")
|
|
244
|
+
return raw[:1].upper() + raw[1:]
|
|
245
|
+
|
|
246
|
+
return ""
|
|
247
|
+
|
|
248
|
+
@staticmethod
|
|
249
|
+
def _primary_ip_value(primary_ip: Any) -> str | None:
|
|
250
|
+
if not primary_ip:
|
|
251
|
+
return None
|
|
252
|
+
address = getattr(primary_ip, "address", None)
|
|
253
|
+
if not address:
|
|
254
|
+
return None
|
|
255
|
+
return str(address).split("/")[0]
|
|
256
|
+
|
|
257
|
+
def _search_with_fallback(
|
|
258
|
+
self,
|
|
259
|
+
*,
|
|
260
|
+
name: str,
|
|
261
|
+
time_range: int | None,
|
|
262
|
+
fallback_ip: str | None = None,
|
|
263
|
+
label_name: str | None = None,
|
|
264
|
+
) -> dict[str, Any]:
|
|
265
|
+
result = self.search_logs(name, time_range=time_range, label_name=label_name)
|
|
266
|
+
result["search_type"] = "name"
|
|
267
|
+
|
|
268
|
+
if result.get("messages") or not self.config.get("fallback_to_ip", True) or not fallback_ip:
|
|
269
|
+
return result
|
|
270
|
+
|
|
271
|
+
fallback = self.search_logs(fallback_ip, time_range=time_range, label_name=label_name)
|
|
272
|
+
fallback["search_type"] = "ip"
|
|
273
|
+
return fallback
|
|
274
|
+
|
|
275
|
+
def get_logs_for_device(self, device: Any, *, time_range: int | None = None) -> dict[str, Any]:
|
|
276
|
+
hostname = device.virtual_chassis.name if getattr(device, "virtual_chassis", None) else device.name
|
|
277
|
+
fallback_ip = self._primary_ip_value(getattr(device, "primary_ip", None))
|
|
278
|
+
result = self._search_with_fallback(
|
|
279
|
+
name=hostname,
|
|
280
|
+
time_range=time_range,
|
|
281
|
+
fallback_ip=fallback_ip,
|
|
282
|
+
)
|
|
283
|
+
result["device_name"] = device.name
|
|
284
|
+
return result
|
|
285
|
+
|
|
286
|
+
def get_logs_for_vm(self, vm: Any, *, time_range: int | None = None) -> dict[str, Any]:
|
|
287
|
+
fallback_ip = self._primary_ip_value(getattr(vm, "primary_ip", None))
|
|
288
|
+
result = self._search_with_fallback(
|
|
289
|
+
name=vm.name,
|
|
290
|
+
time_range=time_range,
|
|
291
|
+
fallback_ip=fallback_ip,
|
|
292
|
+
)
|
|
293
|
+
result["vm_name"] = vm.name
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
def get_logs_for_endpoint(self, endpoint: Any, *, time_range: int | None = None) -> dict[str, Any]:
|
|
297
|
+
search_value = endpoint.name or str(endpoint.mac_address)
|
|
298
|
+
fallback_ip = self._primary_ip_value(getattr(endpoint, "primary_ip", None))
|
|
299
|
+
result = self._search_with_fallback(
|
|
300
|
+
name=search_value,
|
|
301
|
+
time_range=time_range,
|
|
302
|
+
fallback_ip=fallback_ip,
|
|
303
|
+
)
|
|
304
|
+
result["endpoint_name"] = search_value
|
|
305
|
+
return result
|
|
306
|
+
|
|
307
|
+
def _instant_scalar_query(self, query: str) -> int:
|
|
308
|
+
payload = self._request(
|
|
309
|
+
endpoint="/loki/api/v1/query",
|
|
310
|
+
params={
|
|
311
|
+
"query": query,
|
|
312
|
+
"time": self._ns_timestamp(datetime.now(timezone.utc)),
|
|
313
|
+
},
|
|
314
|
+
)
|
|
315
|
+
result = payload.get("data", {}).get("result", [])
|
|
316
|
+
if not result:
|
|
317
|
+
return 0
|
|
318
|
+
|
|
319
|
+
value = result[0].get("value", [None, "0"])
|
|
320
|
+
try:
|
|
321
|
+
return int(float(value[1]))
|
|
322
|
+
except (TypeError, ValueError, IndexError):
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
@staticmethod
|
|
326
|
+
def _duration_expr(time_range: int) -> str:
|
|
327
|
+
if time_range % 86400 == 0:
|
|
328
|
+
return f"{time_range // 86400}d"
|
|
329
|
+
if time_range % 3600 == 0:
|
|
330
|
+
return f"{time_range // 3600}h"
|
|
331
|
+
if time_range % 60 == 0:
|
|
332
|
+
return f"{time_range // 60}m"
|
|
333
|
+
return f"{time_range}s"
|
|
334
|
+
|
|
335
|
+
def get_log_summary(self, time_range: int = 3600, cache_timeout: int = 120) -> dict[str, Any]:
|
|
336
|
+
cache_key = f"loki_log_summary::{time_range}"
|
|
337
|
+
cached = cache.get(cache_key)
|
|
338
|
+
if cached is not None:
|
|
339
|
+
cached["cached"] = True
|
|
340
|
+
return cached
|
|
341
|
+
|
|
342
|
+
selector = self._build_selector()
|
|
343
|
+
range_expr = self._duration_expr(time_range)
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
total = self._instant_scalar_query(f"sum(count_over_time({selector}[{range_expr}]))")
|
|
347
|
+
errors = self._instant_scalar_query(
|
|
348
|
+
f'sum(count_over_time({selector} |~ "(?i)error|err|critical|fatal"[{range_expr}]))'
|
|
349
|
+
)
|
|
350
|
+
warnings = self._instant_scalar_query(
|
|
351
|
+
f'sum(count_over_time({selector} |~ "(?i)warn|warning"[{range_expr}]))'
|
|
352
|
+
)
|
|
353
|
+
except requests.RequestException as exc:
|
|
354
|
+
logger.warning("Loki summary query failed: %s", exc)
|
|
355
|
+
return {"error": str(exc)}
|
|
356
|
+
|
|
357
|
+
summary = {
|
|
358
|
+
"total": total,
|
|
359
|
+
"errors": errors,
|
|
360
|
+
"warnings": warnings,
|
|
361
|
+
"cached": False,
|
|
362
|
+
}
|
|
363
|
+
cache.set(cache_key, summary, cache_timeout)
|
|
364
|
+
return summary
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
LokiClient = LokiClient
|
|
368
|
+
|
|
369
|
+
_client: LokiClient | None = None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def get_client() -> LokiClient:
|
|
373
|
+
"""Get or create the Loki client singleton."""
|
|
374
|
+
global _client
|
|
375
|
+
if _client is None:
|
|
376
|
+
_client = LokiClient()
|
|
377
|
+
return _client
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from django.db import migrations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Migration(migrations.Migration):
|
|
5
|
+
|
|
6
|
+
initial = True
|
|
7
|
+
|
|
8
|
+
dependencies = []
|
|
9
|
+
|
|
10
|
+
operations = [
|
|
11
|
+
migrations.CreateModel(
|
|
12
|
+
name="GraylogPermission",
|
|
13
|
+
fields=[],
|
|
14
|
+
options={
|
|
15
|
+
"managed": False,
|
|
16
|
+
"default_permissions": (),
|
|
17
|
+
"permissions": (("configure_graylog", "Can configure Graylog plugin settings"),),
|
|
18
|
+
},
|
|
19
|
+
),
|
|
20
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from django.db import migrations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class Migration(migrations.Migration):
|
|
5
|
+
|
|
6
|
+
dependencies = [
|
|
7
|
+
("netbox_graylog", "0001_initial"),
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
operations = [
|
|
11
|
+
migrations.RenameModel(
|
|
12
|
+
old_name="GraylogPermission",
|
|
13
|
+
new_name="Graylog",
|
|
14
|
+
),
|
|
15
|
+
]
|