Skip to content

Commit 86585bb

Browse files
committed
Add functionality for making DHCP graphs for a set of prefixes
The function fetch_graph_urls_for_prefixes takes a set of prefixes (e.g. obtained from a models.manage.Prefix or from a models.manage.Vlan) and returns one url per DHCP graph from Graphite related to one or more of the prefixes. Each url returns a JSON with graph data.
1 parent 45c5a81 commit 86585bb

File tree

4 files changed

+187
-5
lines changed

4 files changed

+187
-5
lines changed

python/nav/dhcpstats/common.py

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,20 @@
1818
NAV that wants to make use of DHCP stats.
1919
"""
2020

21+
from collections import defaultdict
2122
from dataclasses import dataclass
22-
from typing import Iterable, Literal, Optional, Union
23+
from typing import Any, Iterable, Literal, Optional, Union
2324

2425
import IPy
2526

27+
from nav.metrics.graphs import (
28+
aliased_series,
29+
diffed_series,
30+
json_graph_url,
31+
nonempty_series,
32+
summed_series,
33+
)
34+
from nav.metrics.names import get_expanded_nodes, safe_name
2635
from nav.metrics.templates import metric_path_for_dhcp
2736

2837

@@ -31,6 +40,133 @@
3140
GraphiteMetric = tuple[str, tuple[float, int]]
3241

3342

43+
def fetch_graph_urls_for_prefixes(prefixes: list[IPy.IP]) -> list[str]:
44+
"""
45+
Takes a list of IP prefixes, queries Graphite for DHCP stat paths, and
46+
returns a list of Graphite graph URLs; one URL for each group of DHCP stat
47+
paths in Graphite where at least one path in the group represents a range of
48+
IP addresses that intersect with one or more of the given prefixes.
49+
50+
Each returned Graphite URL points to JSON graph data and can be supplied
51+
directly to NAV's JavaScript RickshawGraph function.
52+
"""
53+
if not prefixes:
54+
return []
55+
56+
all_paths = fetch_paths_from_graphite()
57+
grouped_paths = group_paths(all_paths)
58+
filtered_grouped_paths = drop_groups_not_in_prefixes(grouped_paths, prefixes)
59+
60+
graph_urls: list[str] = []
61+
for paths_of_same_group in sorted(filtered_grouped_paths):
62+
paths_of_same_group = sorted(paths_of_same_group)
63+
graph_lines = []
64+
for path in paths_of_same_group:
65+
assigned_addresses = aliased_series(
66+
nonempty_series(
67+
path.to_graphite_path("assigned"),
68+
),
69+
name=f"Assigned addresses in {path.first_ip} - {path.last_ip}",
70+
renderer="area",
71+
)
72+
graph_lines.append(assigned_addresses)
73+
74+
assert len(paths_of_same_group) > 0
75+
path = paths_of_same_group[0] # Just select an arbitrary path instance in the group
76+
unassigned_addresses = aliased_series(
77+
diffed_series(
78+
summed_series(
79+
nonempty_series(
80+
path.to_graphite_path("total", wildcard_for_group=True)
81+
),
82+
),
83+
summed_series(
84+
nonempty_series(
85+
path.to_graphite_path("assigned", wildcard_for_group=True)
86+
),
87+
),
88+
),
89+
name="Unassigned addresses",
90+
renderer="area",
91+
color="#d9d9d9",
92+
)
93+
graph_lines.append(unassigned_addresses)
94+
95+
total_addresses = aliased_series(
96+
summed_series(
97+
nonempty_series(
98+
path.to_graphite_path("total", wildcard_for_group=True)
99+
),
100+
),
101+
name="Total addresses",
102+
color="#707070",
103+
)
104+
graph_lines.append(total_addresses)
105+
106+
type_human = path.allocation_type + "s"
107+
title = f"DHCP {type_human} in '{path.group_name}' on server '{path.server_name}'"
108+
graph_urls.append(json_graph_url(*graph_lines, title=title))
109+
return graph_urls
110+
111+
112+
def fetch_paths_from_graphite():
113+
"""
114+
Fetches and returns all unique DHCP stat paths in Graphite when their
115+
trailing metric_name path segment has been removed.
116+
"""
117+
wildcard = metric_path_for_dhcp(
118+
ip_version=safe_name("{4,6}"),
119+
server_name=safe_name("*"),
120+
allocation_type=safe_name("{range,pool,subnet}"),
121+
group_name_source=safe_name("{custom_groups,special_groups}"),
122+
group_name=safe_name("*"),
123+
first_ip=safe_name("*"),
124+
last_ip=safe_name("*"),
125+
metric_name="total",
126+
)
127+
graphite_paths = get_expanded_nodes(wildcard)
128+
129+
native_paths: list[DhcpPath] = []
130+
for graphite_path in graphite_paths:
131+
try:
132+
native_path = DhcpPath.from_graphite_path(graphite_path)
133+
except ValueError:
134+
pass
135+
else:
136+
native_paths.append(native_path)
137+
return native_paths
138+
139+
140+
def group_paths(paths: list["DhcpPath"]):
141+
"""
142+
Takes a list of DhcpPath instances and partitions it into multiple lists,
143+
such that two DhcpPath instances belong to the same list if and only if they
144+
have the same group data.
145+
"""
146+
grouped_paths: dict[Any, list[DhcpPath]] = defaultdict(list)
147+
for path in paths:
148+
group_total = path.to_graphite_path("total", wildcard_for_group=True)
149+
group_assigned = path.to_graphite_path("assigned", wildcard_for_group=True)
150+
grouped_paths[(group_total, group_assigned)].append(path)
151+
return list(grouped_paths.values())
152+
153+
154+
def drop_groups_not_in_prefixes(grouped_paths: list[list["DhcpPath"]], prefixes: list[IPy.IP]):
155+
"""
156+
Takes a list of grouped DhcpPath instances, and return only the groups that
157+
has at least one DhcpPath instance that intersect with one or more of the
158+
given prefixes.
159+
"""
160+
grouped_paths_to_keep: list[list[DhcpPath]] = []
161+
for paths_of_same_group in grouped_paths:
162+
if any(
163+
path.intersects(prefixes)
164+
for path in paths_of_same_group
165+
):
166+
grouped_paths_to_keep.append(paths_of_same_group)
167+
return grouped_paths_to_keep
168+
169+
34170
@dataclass(frozen=True, order=True)
35171
class DhcpPath:
36172
"""

python/nav/metrics/names.py

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,23 @@ def get_all_leaves_below(top, ignored=None):
7777
return list(itertools.chain(*paths))
7878

7979

80+
def get_expanded_nodes(path):
81+
"""
82+
Expands any wildcard in path and returns a list of all matching paths.
83+
84+
:param path: A search string, e.g. "nav.{a,b}.*.*"
85+
:returns: A list of expanded metric paths,
86+
e.g. ["nav.a.1.1", "nav.a.2.1", "nav.b.1.1", "nav.b.1.2"]
87+
"""
88+
data = raw_metric_query(path, operation="expand")
89+
if not isinstance(data, dict):
90+
return []
91+
result = data.get("results", [])
92+
if not isinstance(result, list):
93+
return []
94+
return result
95+
96+
8097
def get_metric_leaf_children(path):
8198
"""Returns a list of available graphite leaf nodes just below path.
8299
@@ -132,15 +149,18 @@ def nodewalk(top, ignored=None):
132149
yield x
133150

134151

135-
def raw_metric_query(query):
152+
def raw_metric_query(query, operation="find"):
136153
"""Runs a query for metric information against Graphite's REST API.
137154
138155
:param query: A search string, e.g. "nav.devices.some-gw_example_org.*"
139-
:returns: A list of matching metrics, each represented by a dict.
156+
:param operation: One of "find" or "expand",
157+
see https://graphite.readthedocs.io/en/1.1.10/metrics_api.html
158+
:returns: The JSON response from Graphite, or an empty list if response
159+
could not be decoded.
140160
141161
"""
142162
base = CONFIG.get("graphiteweb", "base")
143-
url = urljoin(base, "/metrics/find")
163+
url = urljoin(base, "/metrics/" + operation)
144164
query = urlencode({'query': query})
145165
url = "%s?%s" % (url, query)
146166

python/nav/web/static/js/src/plugins/rickshaw_graph.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ define([
6969
serie.key = name;
7070
serie.name = RickshawUtils.filterFunctionCalls(name);
7171
serie.renderer = meta.renderer ? meta.renderer : 'line';
72+
if (meta.color !== undefined) {
73+
serie.color = meta.color;
74+
}
7275

7376
// If this is a nav-metric, typically very long, display only the last two "parts"
7477
if (serie.name.substr(0, 4) === 'nav.') {

tests/unittests/metrics/names_test.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22
from unittest import TestCase
3-
from nav.metrics.names import join_series, escape_metric_name
3+
from unittest.mock import patch
4+
from nav.metrics.names import join_series, escape_metric_name, get_expanded_nodes, safe_name
45

56

67
class MetricNamingTests(TestCase):
@@ -15,6 +16,28 @@ def test_join_single_series_should_return_same(self):
1516
self.assertEqual(join_series([series]), series)
1617

1718

19+
class TestGetExpandedNodes:
20+
def test_when_valid_response_should_return_results(self):
21+
raw_response = {
22+
"results": [
23+
"nav.foo.1",
24+
"nav.foo.2",
25+
"nav.foo.3",
26+
"nav.bar.baz",
27+
]
28+
}
29+
30+
with patch("nav.metrics.names.raw_metric_query", return_value=raw_response):
31+
assert get_expanded_nodes("whatever.path") == raw_response["results"]
32+
33+
@pytest.mark.parametrize(
34+
"raw_response", [[], {}, "foo", "", {"results": "foo"}]
35+
)
36+
def test_when_invalid_response_should_return_empty_list(self, raw_response):
37+
with patch("nav.metrics.names.raw_metric_query", return_value=raw_response):
38+
assert get_expanded_nodes("whatever.path") == []
39+
40+
1841
@pytest.mark.parametrize(
1942
"test_input,expected",
2043
[

0 commit comments

Comments
 (0)