netbox-ipam-treeview-plugin 0.1.0__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.
Files changed (30) hide show
  1. netbox_ipam_treeview/__init__.py +23 -0
  2. netbox_ipam_treeview/columns.py +32 -0
  3. netbox_ipam_treeview/conf.py +23 -0
  4. netbox_ipam_treeview/navigation.py +37 -0
  5. netbox_ipam_treeview/static/netbox_ipam_treeview/tree.css +21 -0
  6. netbox_ipam_treeview/static/netbox_ipam_treeview/tree.js +289 -0
  7. netbox_ipam_treeview/template_content.py +22 -0
  8. netbox_ipam_treeview/templates/netbox_ipam_treeview/inc/list_button.html +1 -0
  9. netbox_ipam_treeview/templates/netbox_ipam_treeview/inc/rows.html +33 -0
  10. netbox_ipam_treeview/templates/netbox_ipam_treeview/inc/table.html +27 -0
  11. netbox_ipam_treeview/templates/netbox_ipam_treeview/inc/toolbar.html +32 -0
  12. netbox_ipam_treeview/templates/netbox_ipam_treeview/tab.html +17 -0
  13. netbox_ipam_treeview/templates/netbox_ipam_treeview/tree.html +57 -0
  14. netbox_ipam_treeview/templatetags/__init__.py +0 -0
  15. netbox_ipam_treeview/templatetags/ipam_tree.py +64 -0
  16. netbox_ipam_treeview/tree/__init__.py +0 -0
  17. netbox_ipam_treeview/tree/builder.py +447 -0
  18. netbox_ipam_treeview/tree/gaps.py +39 -0
  19. netbox_ipam_treeview/tree/nesting.py +89 -0
  20. netbox_ipam_treeview/tree/nodes.py +58 -0
  21. netbox_ipam_treeview/tree/usage.py +46 -0
  22. netbox_ipam_treeview/urls.py +11 -0
  23. netbox_ipam_treeview/utilization.py +100 -0
  24. netbox_ipam_treeview/version.py +1 -0
  25. netbox_ipam_treeview/views.py +236 -0
  26. netbox_ipam_treeview_plugin-0.1.0.dist-info/METADATA +135 -0
  27. netbox_ipam_treeview_plugin-0.1.0.dist-info/RECORD +30 -0
  28. netbox_ipam_treeview_plugin-0.1.0.dist-info/WHEEL +4 -0
  29. netbox_ipam_treeview_plugin-0.1.0.dist-info/entry_points.txt +2 -0
  30. netbox_ipam_treeview_plugin-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,23 @@
1
+ from .conf import DEFAULTS
2
+ from .version import __version__
3
+
4
+ try:
5
+ from netbox.plugins import PluginConfig
6
+ except ImportError: # imported outside NetBox (pure unit tests, build tools)
7
+ PluginConfig = None
8
+
9
+ if PluginConfig is not None:
10
+
11
+ class IPAMAltViewConfig(PluginConfig):
12
+ name = "netbox_ipam_treeview"
13
+ verbose_name = "IPAM Tree"
14
+ description = "DDI-style collapsible tree view for NetBox IPAM prefixes"
15
+ version = __version__
16
+ author = "Thomas Christory"
17
+ author_email = "9317624+thomaschristory@users.noreply.github.com"
18
+ base_url = "ipam-tree"
19
+ min_version = "4.7.0"
20
+ max_version = "4.7.99"
21
+ default_settings = DEFAULTS
22
+
23
+ config = IPAMAltViewConfig
@@ -0,0 +1,32 @@
1
+ from django.utils.translation import gettext_lazy as _
2
+
3
+ from .conf import get_setting
4
+
5
+ USER_CONFIG_PATH = "plugins.netbox_ipam_treeview.columns"
6
+
7
+ COLUMNS = {
8
+ "status": _("Status"),
9
+ "utilization": _("Utilization"),
10
+ "vrf": _("VRF"),
11
+ "scope": _("Scope"),
12
+ "vlan": _("VLAN"),
13
+ "tenant": _("Tenant"),
14
+ "role": _("Role"),
15
+ "children": _("Children"),
16
+ "is_pool": _("Pool"),
17
+ "mark_utilized": _("Marked utilized"),
18
+ "tags": _("Tags"),
19
+ "description": _("Description"),
20
+ "created": _("Created"),
21
+ "last_updated": _("Last updated"),
22
+ "actions": "",
23
+ }
24
+
25
+
26
+ def resolve_columns(user):
27
+ """The user's saved column choice, else the configured default, limited to known columns."""
28
+ chosen = None
29
+ if getattr(user, "is_authenticated", False) and hasattr(user, "config"):
30
+ chosen = user.config.get(USER_CONFIG_PATH)
31
+ chosen = chosen or get_setting("default_columns")
32
+ return [c for c in chosen if c in COLUMNS]
@@ -0,0 +1,23 @@
1
+ PLUGIN_NAME = "netbox_ipam_treeview"
2
+
3
+ DEFAULTS = {
4
+ "show_menu_item": True,
5
+ "menu_location": "ipam", # "ipam": IPAM menu, below "Prefixes"; "plugins": the Plugins menu
6
+ "show_list_toggle": True,
7
+ "show_prefix_tab": True,
8
+ "show_aggregate_tab": True,
9
+ "show_vrf_tab": True,
10
+ "group_by_vrf": True,
11
+ "show_aggregates": True,
12
+ "show_free_space": True,
13
+ "gaps_respect_mark_utilized": True,
14
+ "max_gap_rows": 64,
15
+ "expand_all_limit": 5000,
16
+ "default_columns": ["status", "utilization", "scope", "vlan", "tenant", "role", "description", "actions"],
17
+ }
18
+
19
+
20
+ def get_setting(key):
21
+ from netbox.plugins.utils import get_plugin_config
22
+
23
+ return get_plugin_config(PLUGIN_NAME, key, DEFAULTS[key])
@@ -0,0 +1,37 @@
1
+ from netbox.plugins import PluginMenuItem
2
+
3
+ from .conf import get_setting
4
+
5
+ LINK = "plugins:netbox_ipam_treeview:tree"
6
+ LABEL = "Prefix Tree"
7
+ PERMISSIONS = ["ipam.view_prefix"]
8
+
9
+
10
+ def _add_to_ipam_menu():
11
+ """Insert the tree right after "Prefixes" in NetBox's IPAM menu.
12
+
13
+ Plugins have no API for core menus, so this edits IPAM_MENU (plain dataclasses in NetBox 4.7).
14
+ Returns False when the menu looks different, and the caller falls back to the Plugins menu.
15
+ """
16
+ try:
17
+ from netbox.navigation import MenuItem
18
+ from netbox.navigation.menu import IPAM_MENU
19
+ except ImportError:
20
+ return False
21
+ groups = getattr(IPAM_MENU, "groups", ())
22
+ if any(item.link == LINK for group in groups for item in group.items):
23
+ return True
24
+ for group in groups:
25
+ links = [item.link for item in group.items]
26
+ if "ipam:prefix_list" in links:
27
+ at = links.index("ipam:prefix_list") + 1
28
+ entry = MenuItem(link=LINK, link_text=LABEL, permissions=PERMISSIONS)
29
+ group.items = (*group.items[:at], entry, *group.items[at:])
30
+ return True
31
+ return False
32
+
33
+
34
+ _show = get_setting("show_menu_item")
35
+ _in_ipam = _show and get_setting("menu_location") == "ipam" and _add_to_ipam_menu()
36
+
37
+ menu_items = (PluginMenuItem(link=LINK, link_text=LABEL, permissions=PERMISSIONS),) if _show and not _in_ipam else ()
@@ -0,0 +1,21 @@
1
+ .ipt-tree td.ipt-name { padding-left: calc(0.5rem + var(--ipt-level, 0) * 1.4rem); white-space: nowrap; }
2
+ .ipt-tree .ipt-toggle, .ipt-tree .ipt-spacer { display: inline-block; width: 1.4rem; text-align: center; line-height: 1; }
3
+ .ipt-tree .ipt-toggle { color: inherit; text-decoration: none; }
4
+ .ipt-tree .ipt-toggle i { display: inline-block; transition: transform 0.12s ease; }
5
+ .ipt-tree tr[aria-expanded="true"] > td .ipt-toggle i { transform: rotate(90deg); }
6
+ .ipt-tree tr.ipt-hidden { display: none; }
7
+ .ipt-tree tr.ipt-vrf > td { background: var(--tblr-bg-surface-secondary); font-weight: 600; }
8
+ .ipt-tree tr.ipt-aggregate > td.ipt-name { font-weight: 600; }
9
+ .ipt-tree tr.ipt-gap > td, .ipt-tree tr.ipt-more > td { color: var(--tblr-secondary); font-style: italic; }
10
+ .ipt-tree tr.ipt-gap > td.ipt-name { box-shadow: inset 3px 0 0 var(--tblr-success); }
11
+ .ipt-tree tr.ipt-context > td { opacity: 0.55; }
12
+ .ipt-tree tr.ipt-loading .ipt-toggle i { animation: ipt-spin 0.8s linear infinite; }
13
+ .ipt-tree tr.ipt-failed .ipt-toggle { color: var(--tblr-danger); }
14
+ .ipt-tree tr:focus-visible { outline: 2px solid var(--tblr-primary); outline-offset: -2px; }
15
+ .ipt-tree.ipt-busy { opacity: 0.6; pointer-events: none; }
16
+ .ipt-columns { min-width: 14rem; }
17
+ @keyframes ipt-spin { to { transform: rotate(360deg); } }
18
+ .ipt-tree td { vertical-align: middle; }
19
+ .ipt-tree td.ipt-col-scope, .ipt-tree td.ipt-col-vlan, .ipt-tree td.ipt-col-tenant, .ipt-tree td.ipt-col-role,
20
+ .ipt-tree td.ipt-col-status, .ipt-tree td.ipt-col-vrf { white-space: nowrap; }
21
+ .ipt-tree td.ipt-col-utilization { min-width: 7rem; }
@@ -0,0 +1,289 @@
1
+ // Collapsible IPAM tree: lazy child loading, expand/collapse all, remembered state, keyboard navigation.
2
+ (() => {
3
+ "use strict";
4
+
5
+ const store = {
6
+ get(key, fallback) {
7
+ try {
8
+ const v = localStorage.getItem(key);
9
+ return v === null ? fallback : JSON.parse(v);
10
+ } catch {
11
+ return fallback;
12
+ }
13
+ },
14
+ set(key, value) {
15
+ try {
16
+ localStorage.setItem(key, JSON.stringify(value));
17
+ } catch {
18
+ /* storage unavailable: the tree still works, it just forgets its state */
19
+ }
20
+ },
21
+ };
22
+
23
+ const FREE_KEY = "ipam-tree:free";
24
+ const MAX_SAVED_KEYS = 200; // keeps the restore URL short; ancestors come first in DOM order
25
+
26
+ function init(table) {
27
+ const tbody = table.tBodies[0];
28
+ const ds = table.dataset;
29
+ const filtered = ds.filtered === "1";
30
+ const onPage = ds.rootKey === "__root__" && ds.storageKey === "ipam-tree:page";
31
+ const stateKey = ds.storageKey;
32
+ // Saved state: {all: true} after "Expand all", otherwise {keys: [...]} (a bare array is the old format).
33
+ const saved = store.get(stateKey, {});
34
+ const savedAll = !Array.isArray(saved) && saved.all === true;
35
+ const expanded = new Set(Array.isArray(saved) ? saved : saved.keys || []);
36
+ let allExpanded = false;
37
+ let interacted = false;
38
+ const level = (row) => Number(row.dataset.level);
39
+ const rows = () => Array.from(tbody.querySelectorAll("tr.ipt-row"));
40
+ const isOpen = (row) => row.getAttribute("aria-expanded") === "true";
41
+
42
+ function url(base, params) {
43
+ const u = new URL(base, location.origin);
44
+ Object.entries(params).forEach(([k, v]) => u.searchParams.set(k, v));
45
+ u.searchParams.set("free", ds.free);
46
+ return u;
47
+ }
48
+
49
+ async function fetchRows(u) {
50
+ const resp = await fetch(u, {
51
+ headers: { "X-Requested-With": "XMLHttpRequest", "X-Tree-Page": location.pathname + location.search },
52
+ credentials: "same-origin",
53
+ });
54
+ return { ok: resp.ok, html: await resp.text(), truncated: resp.headers.get("X-Tree-Truncated") === "1" };
55
+ }
56
+
57
+ function descendants(row) {
58
+ const out = [];
59
+ for (let r = row.nextElementSibling; r && level(r) > level(row); r = r.nextElementSibling) out.push(r);
60
+ return out;
61
+ }
62
+
63
+ function save() {
64
+ if (filtered || !onPage) return;
65
+ if (allExpanded) {
66
+ store.set(stateKey, { all: true });
67
+ return;
68
+ }
69
+ const inOrder = rows()
70
+ .map((r) => r.dataset.key)
71
+ .filter((k) => expanded.has(k));
72
+ store.set(stateKey, { keys: inOrder.slice(0, MAX_SAVED_KEYS) });
73
+ }
74
+
75
+ // Show the descendants of `row` that should be visible given each ancestor's expanded state.
76
+ function refreshVisibility(row) {
77
+ let hideBelow = isOpen(row) ? null : level(row);
78
+ for (const r of descendants(row)) {
79
+ if (hideBelow !== null && level(r) > hideBelow) {
80
+ r.classList.add("ipt-hidden");
81
+ continue;
82
+ }
83
+ hideBelow = null;
84
+ r.classList.remove("ipt-hidden");
85
+ if (r.hasAttribute("aria-expanded") && !isOpen(r)) hideBelow = level(r);
86
+ }
87
+ }
88
+
89
+ function banner(show) {
90
+ const card = table.closest(".card");
91
+ let el = card.previousElementSibling;
92
+ if (!el || !el.classList.contains("ipt-banner")) {
93
+ el = document.createElement("div");
94
+ el.className = "alert alert-warning ipt-banner d-none";
95
+ el.textContent = "Row limit reached: showing a partial tree. Narrow the view with filters.";
96
+ card.before(el);
97
+ }
98
+ el.classList.toggle("d-none", !show);
99
+ }
100
+
101
+ function rememberLoaded(fromRows) {
102
+ fromRows.forEach((r) => {
103
+ if (isOpen(r)) expanded.add(r.dataset.key);
104
+ });
105
+ }
106
+
107
+ async function load(row, endpoint) {
108
+ row.classList.add("ipt-loading");
109
+ row.classList.remove("ipt-failed");
110
+ try {
111
+ const res = await fetchRows(url(endpoint, { key: row.dataset.key, level: row.dataset.level }));
112
+ if (!res.ok) throw new Error(`HTTP error loading ${row.dataset.key}`);
113
+ descendants(row).forEach((r) => r.remove());
114
+ row.insertAdjacentHTML("afterend", res.html);
115
+ row.dataset.loaded = "1";
116
+ banner(res.truncated);
117
+ return true;
118
+ } catch (err) {
119
+ console.warn("[ipam-tree]", err);
120
+ row.classList.add("ipt-failed");
121
+ return false;
122
+ } finally {
123
+ row.classList.remove("ipt-loading");
124
+ }
125
+ }
126
+
127
+ async function setExpanded(row, open) {
128
+ allExpanded = false;
129
+ if (open && !row.dataset.loaded && !(await load(row, ds.childrenUrl))) return;
130
+ if (open && descendants(row).length === 0) {
131
+ // Nothing visible underneath (e.g. children hidden by permissions): drop the toggle.
132
+ row.removeAttribute("aria-expanded");
133
+ row.querySelector(".ipt-toggle")?.replaceWith(Object.assign(document.createElement("span"), { className: "ipt-spacer" }));
134
+ return;
135
+ }
136
+ row.setAttribute("aria-expanded", String(open));
137
+ if (open) expanded.add(row.dataset.key);
138
+ else expanded.delete(row.dataset.key);
139
+ refreshVisibility(row);
140
+ save();
141
+ }
142
+
143
+ async function expandSubtree(row) {
144
+ if (!row.hasAttribute("aria-expanded") || !(await load(row, ds.subtreeUrl))) return;
145
+ row.setAttribute("aria-expanded", "true");
146
+ expanded.add(row.dataset.key);
147
+ rememberLoaded(descendants(row));
148
+ refreshVisibility(row);
149
+ save();
150
+ }
151
+
152
+ async function expandAll() {
153
+ if (filtered) {
154
+ // Keep the filtered result: reopen everything already loaded instead of fetching the whole tree.
155
+ rows().forEach((r) => {
156
+ if (r.dataset.loaded) r.setAttribute("aria-expanded", "true");
157
+ r.classList.remove("ipt-hidden");
158
+ });
159
+ return;
160
+ }
161
+ if (!onPage) {
162
+ // Detail tabs: expand each top-level row of this table in turn.
163
+ const top = Math.min(...rows().map(level));
164
+ for (const row of rows().filter((r) => level(r) === top)) await expandSubtree(row);
165
+ return;
166
+ }
167
+ table.classList.add("ipt-busy");
168
+ try {
169
+ const res = await fetchRows(url(ds.subtreeUrl, { key: "__root__" }));
170
+ if (!res.ok) return;
171
+ tbody.innerHTML = res.html;
172
+ banner(res.truncated);
173
+ rememberLoaded(rows());
174
+ allExpanded = true;
175
+ save();
176
+ } finally {
177
+ table.classList.remove("ipt-busy");
178
+ }
179
+ }
180
+
181
+ function collapseAll() {
182
+ const all = rows();
183
+ if (!all.length) return;
184
+ const top = Math.min(...all.map(level));
185
+ all.forEach((r) => {
186
+ if (r.hasAttribute("aria-expanded")) r.setAttribute("aria-expanded", "false");
187
+ r.classList.toggle("ipt-hidden", level(r) > top);
188
+ });
189
+ expanded.clear();
190
+ allExpanded = false;
191
+ save();
192
+ }
193
+
194
+ async function restore() {
195
+ if (filtered || !onPage || (!savedAll && expanded.size === 0)) return;
196
+ const res = savedAll
197
+ ? await fetchRows(url(ds.subtreeUrl, { key: "__root__" }))
198
+ : await fetchRows(url(ds.expandUrl, { keys: Array.from(expanded).slice(0, MAX_SAVED_KEYS).join(",") }));
199
+ // Never overwrite what the user did while the restore was loading.
200
+ if (interacted || !res.ok || !res.html.trim()) return;
201
+ tbody.innerHTML = res.html;
202
+ banner(res.truncated);
203
+ allExpanded = savedAll;
204
+ if (savedAll) rememberLoaded(rows());
205
+ }
206
+
207
+ table.addEventListener("click", (e) => {
208
+ const btn = e.target.closest(".ipt-toggle");
209
+ if (btn) interacted = true;
210
+ if (!btn) return;
211
+ const row = btn.closest("tr");
212
+ if (e.altKey) expandSubtree(row);
213
+ else setExpanded(row, !isOpen(row));
214
+ });
215
+
216
+ table.addEventListener("keydown", (e) => {
217
+ const row = e.target.closest("tr.ipt-row");
218
+ if (!row) return;
219
+ interacted = true;
220
+ const visible = rows().filter((r) => !r.classList.contains("ipt-hidden"));
221
+ const i = visible.indexOf(row);
222
+ const focus = (r) => {
223
+ if (!r) return;
224
+ row.tabIndex = -1;
225
+ r.tabIndex = 0;
226
+ r.focus();
227
+ };
228
+ switch (e.key) {
229
+ case "ArrowDown":
230
+ focus(visible[i + 1]);
231
+ break;
232
+ case "ArrowUp":
233
+ focus(visible[i - 1]);
234
+ break;
235
+ case "ArrowRight":
236
+ if (row.hasAttribute("aria-expanded") && !isOpen(row)) setExpanded(row, true);
237
+ else focus(visible[i + 1]);
238
+ break;
239
+ case "ArrowLeft":
240
+ if (isOpen(row)) setExpanded(row, false);
241
+ else if (row.dataset.parent) focus(tbody.querySelector(`tr[data-key="${CSS.escape(row.dataset.parent)}"]`));
242
+ break;
243
+ case "*":
244
+ expandSubtree(row);
245
+ break;
246
+ case "Enter": {
247
+ const a = row.querySelector("td.ipt-name a");
248
+ if (a) a.click();
249
+ break;
250
+ }
251
+ default:
252
+ return;
253
+ }
254
+ e.preventDefault();
255
+ });
256
+
257
+ const mark = (fn) => () => {
258
+ interacted = true;
259
+ fn();
260
+ };
261
+ document.querySelectorAll('[data-ipt-action="expand-all"]').forEach((b) => b.addEventListener("click", mark(expandAll)));
262
+ document.querySelectorAll('[data-ipt-action="collapse-all"]').forEach((b) => b.addEventListener("click", mark(collapseAll)));
263
+ document.querySelectorAll('[data-ipt-action="toggle-free"]').forEach((sw) => {
264
+ sw.addEventListener("change", () => {
265
+ store.set(FREE_KEY, sw.checked);
266
+ ds.free = sw.checked ? "1" : "0";
267
+ // Reload so already-expanded nodes are re-rendered with or without free space.
268
+ const u = new URL(location.href);
269
+ u.searchParams.set("free", ds.free);
270
+ location.assign(u);
271
+ });
272
+ });
273
+
274
+ // A remembered free-space preference applies when the URL does not say otherwise.
275
+ if (!new URL(location.href).searchParams.has("free")) {
276
+ const saved = store.get(FREE_KEY, null);
277
+ if (saved !== null) {
278
+ ds.free = saved ? "1" : "0";
279
+ const sw = document.getElementById("ipt-free");
280
+ if (sw) sw.checked = saved;
281
+ }
282
+ }
283
+ const first = tbody.querySelector("tr.ipt-row");
284
+ if (first) first.tabIndex = 0;
285
+ restore();
286
+ }
287
+
288
+ document.addEventListener("DOMContentLoaded", () => document.querySelectorAll("table.ipt-tree").forEach(init));
289
+ })();
@@ -0,0 +1,22 @@
1
+ from django.urls import reverse
2
+ from netbox.plugins import PluginTemplateExtension
3
+
4
+ from .conf import get_setting
5
+
6
+
7
+ class PrefixListTreeButton(PluginTemplateExtension):
8
+ """'Tree view' button on the native prefix list, carrying the current filters over."""
9
+
10
+ models = ["ipam.prefix"]
11
+
12
+ def list_buttons(self):
13
+ if not get_setting("show_list_toggle"):
14
+ return ""
15
+ from .views import filter_querystring
16
+
17
+ query = filter_querystring(self.context["request"].GET)
18
+ url = reverse("plugins:netbox_ipam_treeview:tree") + (f"?{query}" if query else "")
19
+ return self.render("netbox_ipam_treeview/inc/list_button.html", extra_context={"tree_url": url})
20
+
21
+
22
+ template_extensions = [PrefixListTreeButton]
@@ -0,0 +1 @@
1
+ {% load i18n %}<a href="{{ tree_url }}" class="btn btn-outline-primary ipt-tree-toggle"><i class="mdi mdi-file-tree"></i> {% trans "Tree view" %}</a>
@@ -0,0 +1,33 @@
1
+ {% load i18n helpers ipam_tree %}{% if error %}
2
+ <tr class="ipt-error"><td colspan="99" class="text-danger"><i class="mdi mdi-alert"></i> {% trans "Not found or no permission." %}</td></tr>
3
+ {% else %}{% for node in nodes %}
4
+ <tr class="ipt-row ipt-{{ node.kind }}{% if node.context %} ipt-context{% endif %}" role="row" tabindex="-1"
5
+ data-key="{{ node.key }}" data-parent="{{ node.parent_key|default:'' }}" data-level="{{ node.level }}" aria-level="{{ node.level|add:1 }}"
6
+ {% if node.has_children %}aria-expanded="{{ node.expanded|yesno:'true,false' }}"{% endif %}{% if node.expanded and not node.partial %} data-loaded="1"{% endif %}
7
+ style="--ipt-level: {{ node.level }}">
8
+ <td class="ipt-name">
9
+ {% if node.has_children %}<button type="button" class="ipt-toggle btn btn-sm btn-link p-0" tabindex="-1" aria-label="{% trans 'Expand or collapse' %}"><i class="mdi mdi-chevron-right"></i></button>{% else %}<span class="ipt-spacer"></span>{% endif %}
10
+ {% if node.kind == "vrf" %}
11
+ <i class="mdi mdi-layers-outline"></i>
12
+ {% if node.obj %}<a href="{{ node.obj.get_absolute_url }}">{{ node.obj.name }}</a>{% if node.obj.rd %} <span class="text-muted small">{{ node.obj.rd }}</span>{% endif %}{% else %}{% trans "Global" %}{% endif %}
13
+ {% elif node.kind == "aggregate" %}
14
+ <i class="mdi mdi-earth"></i> <a href="{% obj_url node.obj %}">{{ node.obj.prefix }}</a>
15
+ <span class="badge text-bg-secondary">{{ node.obj.rir }}</span>{% if node.label %} <span class="text-muted small">· {{ node.label }}</span>{% endif %}
16
+ {% elif node.kind == "prefix" %}
17
+ <i class="mdi {% if node.has_children %}mdi-folder-network-outline{% else %}mdi-lan{% endif %}"></i>
18
+ <a href="{% obj_url node.obj %}">{{ node.obj.prefix }}</a>
19
+ {% elif node.kind == "gap" %}
20
+ <i class="mdi mdi-checkbox-blank-outline"></i> {{ node.network }}
21
+ <span class="small">· {{ node.network.size }} {% trans "addresses free" %}</span>
22
+ {% if tree_perms.add %}<a class="ms-2" href="{{ prefix_add_url }}?prefix={{ node.network|urlencode }}{% if node.vrf_id and node.vrf_id != '*' %}&vrf={{ node.vrf_id }}{% endif %}&return_url={{ return_url|urlencode }}"><i class="mdi mdi-plus"></i> {% trans "Create" %}</a>{% endif %}
23
+ {% elif node.kind == "more" %}
24
+ <i class="mdi mdi-dots-horizontal"></i> {% blocktrans with c=node.count a=node.addresses %}{{ c }} more free blocks ({{ a }} addresses){% endblocktrans %}
25
+ {% endif %}
26
+ </td>
27
+ {% for col in columns %}<td class="ipt-col-{{ col }}">{% if node.kind == "prefix" %}{% if col == "status" %}{% badge node.obj.get_status_display bg_color=node.obj.get_status_color %}{% elif col == "utilization" %}{{ node.utilization|util_bar }}{% elif col == "vrf" %}{{ node.obj.vrf|obj_link }}{% elif col == "scope" %}{{ node.obj.scope|obj_link }}{% elif col == "vlan" %}{{ node.obj.vlan|obj_link }}{% elif col == "tenant" %}{{ node.obj.tenant|obj_link }}{% elif col == "role" %}{{ node.obj.role|obj_link }}{% elif col == "children" %}{{ node.child_count|placeholder }}{% elif col == "is_pool" %}{% checkmark node.obj.is_pool %}{% elif col == "mark_utilized" %}{% checkmark node.obj.mark_utilized %}{% elif col == "tags" %}{% for t in node.obj.tags.all %}{% tag t %}{% endfor %}{% elif col == "description" %}{{ node.obj.description|placeholder }}{% elif col == "created" %}{{ node.obj.created|isodate }}{% elif col == "last_updated" %}{{ node.obj.last_updated|isodatetime }}{% elif col == "actions" %}<span class="text-nowrap">
28
+ {% if tree_perms.add %}<a class="btn btn-sm btn-ghost-success" title="{% trans 'Add child prefix' %}" href="{{ prefix_add_url }}?{% if node.obj.vrf_id %}vrf={{ node.obj.vrf_id }}&{% endif %}scope_type={{ node.obj.scope_type_id|default:'' }}&scope={{ node.obj.scope_id|default:'' }}&return_url={{ return_url|urlencode }}"><i class="mdi mdi-plus-thick"></i></a>{% endif %}
29
+ {% if tree_perms.change %}<a class="btn btn-sm btn-ghost-warning" title="{% trans 'Edit' %}" href="{% obj_url node.obj 'edit' %}?return_url={{ return_url|urlencode }}"><i class="mdi mdi-pencil"></i></a>{% endif %}
30
+ {% if tree_perms.delete %}<a class="btn btn-sm btn-ghost-danger" title="{% trans 'Delete' %}" href="{% obj_url node.obj 'delete' %}?return_url={{ return_url|urlencode }}"><i class="mdi mdi-trash-can-outline"></i></a>{% endif %}
31
+ </span>{% endif %}{% elif node.kind == "aggregate" and col == "utilization" %}{% utilization_graph node.obj.get_utilization %}{% endif %}</td>{% endfor %}
32
+ </tr>
33
+ {% endfor %}{% endif %}
@@ -0,0 +1,27 @@
1
+ {% load i18n %}
2
+ <div class="card">
3
+ <div class="table-responsive">
4
+ <table class="table table-hover ipt-tree" role="treegrid" aria-label="{% trans 'Prefix tree' %}"
5
+ data-children-url="{% url 'plugins:netbox_ipam_treeview:children' %}"
6
+ data-subtree-url="{% url 'plugins:netbox_ipam_treeview:subtree' %}"
7
+ data-expand-url="{% url 'plugins:netbox_ipam_treeview:expand' %}"
8
+ data-storage-key="{{ storage_key }}" data-free="{{ free|yesno:'1,0' }}"
9
+ data-filtered="{{ filtered|yesno:'1,0' }}" data-root-key="{{ root_key }}">
10
+ <thead>
11
+ <tr>
12
+ <th>{% trans "Prefix" %}</th>
13
+ {% for key, label in column_headers %}<th>{{ label }}</th>{% endfor %}
14
+ </tr>
15
+ </thead>
16
+ <tbody>
17
+ {% if nodes %}
18
+ {% include 'netbox_ipam_treeview/inc/rows.html' %}
19
+ {% else %}
20
+ <tr class="ipt-empty">
21
+ <td colspan="{{ columns|length|add:1 }}" class="text-center text-muted py-4">{% trans "No prefixes to show." %}</td>
22
+ </tr>
23
+ {% endif %}
24
+ </tbody>
25
+ </table>
26
+ </div>
27
+ </div>
@@ -0,0 +1,32 @@
1
+ {% load i18n %}
2
+ <div class="d-flex flex-wrap gap-2 align-items-center mb-2 ipt-toolbar">
3
+ <button type="button" class="btn btn-primary" data-ipt-action="expand-all">
4
+ <i class="mdi mdi-arrow-expand-vertical"></i> {% trans "Expand all" %}
5
+ </button>
6
+ <button type="button" class="btn btn-outline-primary" data-ipt-action="collapse-all">
7
+ <i class="mdi mdi-arrow-collapse-vertical"></i> {% trans "Collapse all" %}
8
+ </button>
9
+ {% if not filtered %}
10
+ <div class="form-check form-switch ms-2 mb-0">
11
+ <input class="form-check-input" type="checkbox" id="ipt-free" data-ipt-action="toggle-free"{% if free %} checked{% endif %}>
12
+ <label class="form-check-label" for="ipt-free">{% trans "Free space" %}</label>
13
+ </div>
14
+ {% endif %}
15
+ <div class="dropdown ms-auto">
16
+ <button class="btn btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" data-bs-auto-close="outside">
17
+ <i class="mdi mdi-table-cog"></i> {% trans "Columns" %}
18
+ </button>
19
+ <form method="post" action="{% url 'plugins:netbox_ipam_treeview:columns' %}" class="dropdown-menu dropdown-menu-end p-3 ipt-columns">
20
+ {% csrf_token %}
21
+ <input type="hidden" name="next" value="{{ request.get_full_path }}">
22
+ {% for key, label in all_columns.items %}{% if label %}
23
+ <div class="form-check">
24
+ <input class="form-check-input" type="checkbox" name="columns" value="{{ key }}" id="ipt-col-{{ key }}"{% if key in columns %} checked{% endif %}>
25
+ <label class="form-check-label" for="ipt-col-{{ key }}">{{ label }}</label>
26
+ </div>
27
+ {% endif %}{% endfor %}
28
+ <input type="hidden" name="columns" value="actions">
29
+ <button type="submit" class="btn btn-sm btn-primary mt-2">{% trans "Apply" %}</button>
30
+ </form>
31
+ </div>
32
+ </div>
@@ -0,0 +1,17 @@
1
+ {% extends base_template %}
2
+ {% load static %}
3
+
4
+ {% block head %}
5
+ {{ block.super }}
6
+ <link rel="stylesheet" href="{% static 'netbox_ipam_treeview/tree.css' %}">
7
+ {% endblock %}
8
+
9
+ {% block content %}
10
+ {% include 'netbox_ipam_treeview/inc/toolbar.html' %}
11
+ {% include 'netbox_ipam_treeview/inc/table.html' %}
12
+ {% endblock %}
13
+
14
+ {% block javascript %}
15
+ {{ block.super }}
16
+ <script src="{% static 'netbox_ipam_treeview/tree.js' %}"></script>
17
+ {% endblock %}
@@ -0,0 +1,57 @@
1
+ {% extends 'generic/_base.html' %}
2
+ {% load static i18n helpers %}
3
+
4
+ {% block title %}{% trans "Prefix Tree" %}{% endblock %}
5
+
6
+ {% block head %}
7
+ <link rel="stylesheet" href="{% static 'netbox_ipam_treeview/tree.css' %}">
8
+ {% endblock %}
9
+
10
+ {% block controls %}
11
+ <div class="btn-list">
12
+ <a href="{% url 'ipam:prefix_list' %}{% if list_querystring %}?{{ list_querystring }}{% endif %}" class="btn btn-outline-secondary">
13
+ <i class="mdi mdi-format-list-bulleted"></i> {% trans "List view" %}
14
+ </a>
15
+ {% if tree_perms.add %}
16
+ <a href="{% url 'ipam:prefix_add' %}?return_url={{ request.get_full_path|urlencode }}" class="btn btn-primary">
17
+ <i class="mdi mdi-plus-thick"></i> {% trans "Add" %}
18
+ </a>
19
+ {% endif %}
20
+ </div>
21
+ {% endblock %}
22
+
23
+ {% block tabs %}
24
+ <ul class="nav nav-tabs" role="tablist">
25
+ <li class="nav-item" role="presentation">
26
+ <button class="nav-link active" id="tree-tab" data-bs-toggle="tab" data-bs-target="#tree-pane" type="button" role="tab" aria-controls="tree-pane" aria-selected="true">
27
+ {% trans "Tree" %}
28
+ </button>
29
+ </li>
30
+ <li class="nav-item" role="presentation">
31
+ <button class="nav-link" id="filters-form-tab" data-bs-toggle="tab" data-bs-target="#filters-form" type="button" role="tab" aria-controls="filters-form" aria-selected="false">
32
+ {% trans "Filters" %}
33
+ {% badge filter_form.changed_data|length bg_color="primary" %}
34
+ </button>
35
+ </li>
36
+ </ul>
37
+ {% endblock tabs %}
38
+
39
+ {% block content %}
40
+ <div class="tab-pane show active" id="tree-pane" role="tabpanel" aria-labelledby="tree-tab">
41
+ {% applied_filters model filter_form request.GET %}
42
+ {% include 'netbox_ipam_treeview/inc/toolbar.html' %}
43
+ {% if truncated %}
44
+ <div class="alert alert-warning">
45
+ {% blocktrans %}Showing the first {{ limit }} rows. Narrow the view with filters.{% endblocktrans %}
46
+ </div>
47
+ {% endif %}
48
+ {% include 'netbox_ipam_treeview/inc/table.html' %}
49
+ </div>
50
+ <div class="tab-pane show" id="filters-form" role="tabpanel" aria-labelledby="filters-form-tab">
51
+ {% include 'inc/filter_list.html' %}
52
+ </div>
53
+ {% endblock content %}
54
+
55
+ {% block javascript %}
56
+ <script src="{% static 'netbox_ipam_treeview/tree.js' %}"></script>
57
+ {% endblock %}
File without changes