feat: review detected networks in one import plan
Show a combined network and VRF dialog only when selected VMs introduce missing prefixes. Allow a different existing or newly named VRF per network and cache existing prefixes to prevent repeated prompts.
This commit is contained in:
@@ -9,9 +9,9 @@ Windows-Desktopanwendung zum Importieren und Synchronisieren virtueller Maschine
|
||||
- Mit Windows DPAPI verschlüsselte Passwörter, API-Token und Token-Secrets
|
||||
- Automatische Übernahme des Cluster-Mandanten mit verbindlicher Bestätigung vor dem Sync
|
||||
- Mandantenzuweisung für IPv4-/IPv6-Adressen und erkannte NetBox-Präfixe
|
||||
- Bestätigungsdialog mit Subnetzgrößen- und vorausgefüllter VRF-Auswahl sowie optionaler VRF-Erstellung
|
||||
- Gemeinsame Importmaske für alle neu erkannten Netze mit Subnetzgröße und vorausgefüllter VRF-Auswahl
|
||||
- Mehrere neue VRFs direkt in der Netzwerkmaske anlegen; Namensfelder erscheinen nur bei Bedarf
|
||||
- Auswahl vorhandener Mandanten-VRFs oder automatische VRF-Erstellung pro Cluster mit frei wählbarem Namen
|
||||
- Eigenes Fenster zur Stapelanlage mehrerer VRFs und Netzwerke pro Mandant
|
||||
- Wiederverwendung bereits vorhandener, zur IP passender Präfixe bei erneuten Imports
|
||||
- Optionale Unterstützung selbstsignierter TLS-Zertifikate
|
||||
- Synchronisierung von VMs, Hardwaredaten, Interfaces und IP-Adressen
|
||||
|
||||
@@ -213,9 +213,13 @@ class VRFSelectionDialog(tk.Toplevel):
|
||||
wraplength=490,
|
||||
justify="left",
|
||||
).pack(anchor="w", pady=(8, 14))
|
||||
ttk.Combobox(body, textvariable=self.selection, values=values, state="readonly").pack(fill="x")
|
||||
ttk.Label(body, text="Name bei automatischer Erstellung").pack(anchor="w", pady=(14, 4))
|
||||
ttk.Entry(body, textvariable=self.new_vrf_name).pack(fill="x")
|
||||
selection_combo = ttk.Combobox(body, textvariable=self.selection, values=values, state="readonly")
|
||||
selection_combo.pack(fill="x")
|
||||
selection_combo.bind("<<ComboboxSelected>>", self.update_name_field)
|
||||
self.name_frame = ttk.Frame(body)
|
||||
ttk.Label(self.name_frame, text="Name der neuen VRF").pack(anchor="w", pady=(14, 4))
|
||||
ttk.Entry(self.name_frame, textvariable=self.new_vrf_name).pack(fill="x")
|
||||
self.update_name_field()
|
||||
|
||||
buttons = ttk.Frame(body)
|
||||
buttons.pack(anchor="e", pady=(22, 0))
|
||||
@@ -224,6 +228,12 @@ class VRFSelectionDialog(tk.Toplevel):
|
||||
self.protocol("WM_DELETE_WINDOW", self.destroy)
|
||||
self.wait_window(self)
|
||||
|
||||
def update_name_field(self, _event=None):
|
||||
if self.selection.get() == self.AUTO_CREATE:
|
||||
self.name_frame.pack(fill="x")
|
||||
else:
|
||||
self.name_frame.pack_forget()
|
||||
|
||||
def accept(self):
|
||||
selected = self.selection.get()
|
||||
if selected == self.AUTO_CREATE:
|
||||
@@ -394,6 +404,180 @@ class PrefixCreationDialog(tk.Toplevel):
|
||||
self.destroy()
|
||||
|
||||
|
||||
class DetectedNetworksDialog(tk.Toplevel):
|
||||
"""Review all newly detected networks and their VRFs in one import plan."""
|
||||
|
||||
AUTO_CREATE = "➕ Neue VRF anlegen"
|
||||
|
||||
def __init__(self, parent, networks, vrfs, default_vrf_id, tenant_name):
|
||||
super().__init__(parent)
|
||||
self.title("Neue Netzwerke prüfen")
|
||||
self.geometry("1060x620")
|
||||
self.minsize(900, 480)
|
||||
self.transient(parent)
|
||||
self.grab_set()
|
||||
self.result = None
|
||||
self.rows = []
|
||||
self.vrf_map = {}
|
||||
|
||||
vrf_values = []
|
||||
for vrf in vrfs:
|
||||
rd = getattr(vrf, "rd", None)
|
||||
display = f"{vrf.name} (RD: {rd})" if rd else vrf.name
|
||||
self.vrf_map[display] = vrf.id
|
||||
vrf_values.append(display)
|
||||
vrf_values.append(self.AUTO_CREATE)
|
||||
default_vrf = next(
|
||||
(display for display, vrf_id in self.vrf_map.items() if vrf_id == default_vrf_id),
|
||||
vrf_values[0],
|
||||
)
|
||||
|
||||
body = ttk.Frame(self, padding=20)
|
||||
body.pack(fill="both", expand=True)
|
||||
ttk.Label(body, text="Neue Netzwerke erkannt", font=("Segoe UI Semibold", 16)).pack(anchor="w")
|
||||
ttk.Label(
|
||||
body,
|
||||
text=(
|
||||
"Nur noch nicht vorhandene Netze werden angezeigt. Subnetzgröße und VRF können pro Netz geändert "
|
||||
"werden. Bei ‚Neue VRF anlegen‘ erscheint ein Namensfeld in derselben Zeile."
|
||||
),
|
||||
wraplength=980,
|
||||
foreground="#52606d",
|
||||
).pack(anchor="w", pady=(5, 14))
|
||||
|
||||
header = ttk.Frame(body)
|
||||
header.pack(fill="x", padx=(2, 20))
|
||||
ttk.Label(header, text="Anlegen", width=8).grid(row=0, column=0, sticky="w")
|
||||
ttk.Label(header, text="Erkannte IP", width=21).grid(row=0, column=1, sticky="w")
|
||||
ttk.Label(header, text="Größe", width=9).grid(row=0, column=2, sticky="w")
|
||||
ttk.Label(header, text="Netzwerk", width=25).grid(row=0, column=3, sticky="w")
|
||||
ttk.Label(header, text="VRF", width=34).grid(row=0, column=4, sticky="w")
|
||||
|
||||
container = ttk.Frame(body)
|
||||
container.pack(fill="both", expand=True, pady=(4, 10))
|
||||
canvas = tk.Canvas(container, borderwidth=0, highlightthickness=0)
|
||||
scrollbar = ttk.Scrollbar(container, orient="vertical", command=canvas.yview)
|
||||
self.rows_frame = ttk.Frame(canvas)
|
||||
self.rows_frame.bind(
|
||||
"<Configure>",
|
||||
lambda _event: canvas.configure(scrollregion=canvas.bbox("all")),
|
||||
)
|
||||
window_id = canvas.create_window((0, 0), window=self.rows_frame, anchor="nw")
|
||||
canvas.bind("<Configure>", lambda event: canvas.itemconfigure(window_id, width=event.width))
|
||||
canvas.configure(yscrollcommand=scrollbar.set)
|
||||
canvas.pack(side="left", fill="both", expand=True)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
|
||||
for index, item in enumerate(networks):
|
||||
self.add_network_row(index, item, vrf_values, default_vrf, tenant_name)
|
||||
|
||||
buttons = ttk.Frame(body)
|
||||
buttons.pack(fill="x")
|
||||
ttk.Button(buttons, text="Import abbrechen", command=self.destroy).pack(side="left")
|
||||
ttk.Button(buttons, text="Ohne neue Netze fortfahren", command=self.skip_all).pack(side="right", padx=(8, 0))
|
||||
ttk.Button(buttons, text="Auswahl übernehmen", command=self.accept, style="Primary.TButton").pack(side="right")
|
||||
self.protocol("WM_DELETE_WINDOW", self.destroy)
|
||||
self.wait_window(self)
|
||||
|
||||
def add_network_row(self, index, item, vrf_values, default_vrf, tenant_name):
|
||||
row_frame = ttk.Frame(self.rows_frame, padding=(4, 7))
|
||||
row_frame.pack(fill="x")
|
||||
enabled = tk.BooleanVar(value=True)
|
||||
prefix_length = tk.StringVar(value=f"/{item['suggested_prefixlen']}")
|
||||
network_text = tk.StringVar()
|
||||
vrf_selection = tk.StringVar(value=default_vrf)
|
||||
new_vrf_name = tk.StringVar(value=f"{tenant_name} - {item['suggested_network'].network_address}")
|
||||
|
||||
ttk.Checkbutton(row_frame, variable=enabled).grid(row=0, column=0, sticky="w", padx=(0, 12))
|
||||
ttk.Label(row_frame, text=str(item["host_address"]), width=21).grid(row=0, column=1, sticky="w")
|
||||
choices = self.prefix_choices(item["host_address"], item["detected_prefixlen"])
|
||||
size_combo = ttk.Combobox(row_frame, textvariable=prefix_length, values=choices, state="readonly", width=7)
|
||||
size_combo.grid(row=0, column=2, sticky="w", padx=(0, 12))
|
||||
ttk.Label(row_frame, textvariable=network_text, width=25).grid(row=0, column=3, sticky="w")
|
||||
vrf_combo = ttk.Combobox(
|
||||
row_frame,
|
||||
textvariable=vrf_selection,
|
||||
values=vrf_values,
|
||||
state="readonly",
|
||||
width=34,
|
||||
)
|
||||
vrf_combo.grid(row=0, column=4, sticky="ew")
|
||||
row_frame.columnconfigure(4, weight=1)
|
||||
|
||||
name_frame = ttk.Frame(row_frame)
|
||||
ttk.Label(name_frame, text="Name der neuen VRF:").pack(side="left")
|
||||
ttk.Entry(name_frame, textvariable=new_vrf_name).pack(side="left", fill="x", expand=True, padx=(8, 0))
|
||||
|
||||
row = {
|
||||
"frame": row_frame,
|
||||
"enabled": enabled,
|
||||
"host_address": item["host_address"],
|
||||
"prefix_length": prefix_length,
|
||||
"network_text": network_text,
|
||||
"vrf_selection": vrf_selection,
|
||||
"new_vrf_name": new_vrf_name,
|
||||
"name_frame": name_frame,
|
||||
}
|
||||
self.rows.append(row)
|
||||
|
||||
size_combo.bind("<<ComboboxSelected>>", lambda _event, current=row: self.update_row(current))
|
||||
vrf_combo.bind("<<ComboboxSelected>>", lambda _event, current=row: self.update_row(current))
|
||||
self.update_row(row)
|
||||
|
||||
@staticmethod
|
||||
def prefix_choices(host_address, detected_prefixlen):
|
||||
host = ipaddress.ip_address(host_address)
|
||||
if host.version == 4:
|
||||
return [f"/{length}" for length in range(8, 25)]
|
||||
common = {32, 40, 48, 52, 56, 60, 64, 80, 96, 112, 120, 124, 126, 127, 128, detected_prefixlen}
|
||||
return [f"/{length}" for length in sorted(common)]
|
||||
|
||||
def update_row(self, row):
|
||||
length = int(row["prefix_length"].get().lstrip("/"))
|
||||
network = ipaddress.ip_network(f"{row['host_address']}/{length}", strict=False)
|
||||
row["network_text"].set(str(network))
|
||||
if row["vrf_selection"].get() == self.AUTO_CREATE:
|
||||
row["name_frame"].grid(row=1, column=4, sticky="ew", pady=(7, 0))
|
||||
else:
|
||||
row["name_frame"].grid_remove()
|
||||
|
||||
def skip_all(self):
|
||||
self.result = []
|
||||
self.destroy()
|
||||
|
||||
def accept(self):
|
||||
decisions = {}
|
||||
for row in self.rows:
|
||||
if not row["enabled"].get():
|
||||
continue
|
||||
network = row["network_text"].get()
|
||||
selected_vrf = row["vrf_selection"].get()
|
||||
if selected_vrf == self.AUTO_CREATE:
|
||||
vrf_name = row["new_vrf_name"].get().strip()
|
||||
if not vrf_name:
|
||||
messagebox.showwarning("VRF-Name fehlt", f"Bitte für {network} einen VRF-Namen eingeben.", parent=self)
|
||||
return
|
||||
decision = {"network": network, "vrf_id": None, "create_vrf": True, "vrf_name": vrf_name}
|
||||
else:
|
||||
decision = {
|
||||
"network": network,
|
||||
"vrf_id": self.vrf_map[selected_vrf],
|
||||
"create_vrf": False,
|
||||
"vrf_name": None,
|
||||
}
|
||||
existing = decisions.get(network)
|
||||
if existing and existing != decision:
|
||||
messagebox.showwarning(
|
||||
"Netzwerk doppelt",
|
||||
f"{network} wurde mit unterschiedlichen VRFs ausgewählt.",
|
||||
parent=self,
|
||||
)
|
||||
return
|
||||
decisions[network] = decision
|
||||
self.result = list(decisions.values())
|
||||
self.destroy()
|
||||
|
||||
|
||||
class NetworkManagementDialog(tk.Toplevel):
|
||||
"""Create multiple tenant VRFs and prefixes in one dedicated window."""
|
||||
|
||||
@@ -663,7 +847,6 @@ class NetBoxVMImporter:
|
||||
buttons = ttk.Frame(main)
|
||||
buttons.grid(row=5, column=0, sticky="ew", pady=10)
|
||||
ttk.Button(buttons, text="Alle auswählen", command=self.select_all).pack(side="left")
|
||||
ttk.Button(buttons, text="VRFs & Netzwerke", command=self.open_network_manager).pack(side="left", padx=8)
|
||||
ttk.Button(buttons, text="Import / Sync starten", command=self.import_vms, style="Primary.TButton").pack(side="right")
|
||||
|
||||
log_card = ttk.LabelFrame(main, text="Aktivitätsprotokoll", style="Card.TLabelframe")
|
||||
@@ -1232,7 +1415,11 @@ class NetBoxVMImporter:
|
||||
self.log("Synchronisierung ohne VRF-Auswahl abgebrochen")
|
||||
return
|
||||
self.synced_prefixes = set()
|
||||
self.prefix_decisions = {}
|
||||
prefix_plans = self.prepare_network_import(selected, tenant_name, tenant_id, vrf_id)
|
||||
if prefix_plans is None:
|
||||
self.log("Synchronisierung in der Netzwerkprüfung abgebrochen")
|
||||
return
|
||||
self.prefix_plans = prefix_plans
|
||||
failures = []
|
||||
for item_id in selected:
|
||||
vm_data = self.vms[int(item_id)]
|
||||
@@ -1279,6 +1466,81 @@ class NetBoxVMImporter:
|
||||
key=lambda item: item.name.casefold(),
|
||||
)
|
||||
|
||||
def prepare_network_import(self, selected_items, tenant_name, tenant_id, default_vrf_id):
|
||||
detected = {}
|
||||
for item_id in selected_items:
|
||||
vm_data = self.vms[int(item_id)]
|
||||
for interface in vm_data.get("interfaces", []):
|
||||
for ip_value in dict.fromkeys(interface.get("ips", [])):
|
||||
address = self.ip_with_prefix(ip_value)
|
||||
if not address:
|
||||
continue
|
||||
parsed = ipaddress.ip_interface(address)
|
||||
if parsed.version == 4:
|
||||
suggested_prefixlen = 24
|
||||
else:
|
||||
suggested_prefixlen = parsed.network.prefixlen
|
||||
suggested_network = ipaddress.ip_network(
|
||||
f"{parsed.ip}/{suggested_prefixlen}",
|
||||
strict=False,
|
||||
)
|
||||
key = str(suggested_network)
|
||||
detected.setdefault(
|
||||
key,
|
||||
{
|
||||
"host_address": parsed.ip,
|
||||
"host_addresses": set(),
|
||||
"detected_prefixlen": parsed.network.prefixlen,
|
||||
"suggested_prefixlen": suggested_prefixlen,
|
||||
"suggested_network": suggested_network,
|
||||
},
|
||||
)["host_addresses"].add(parsed.ip)
|
||||
|
||||
tenant_vrf_ids = {vrf.id for vrf in self.tenant_vrfs(tenant_id)}
|
||||
self.preexisting_prefixes = []
|
||||
missing = []
|
||||
for item in detected.values():
|
||||
group_is_missing = False
|
||||
for host in item["host_addresses"]:
|
||||
covering = self.find_covering_prefixes(host, tenant_vrf_ids)
|
||||
default_matches = [match for match in covering if match[3] == default_vrf_id]
|
||||
matching_vrfs = {match[3] for match in covering}
|
||||
reusable = None
|
||||
if default_matches:
|
||||
reusable = max(default_matches, key=lambda match: match[0])
|
||||
elif len(matching_vrfs) == 1:
|
||||
reusable = max(covering, key=lambda match: match[0])
|
||||
if reusable:
|
||||
_, prefix, existing_network, existing_vrf_id = reusable
|
||||
cached = (prefix, existing_network, existing_vrf_id)
|
||||
if cached not in self.preexisting_prefixes:
|
||||
self.preexisting_prefixes.append(cached)
|
||||
else:
|
||||
group_is_missing = True
|
||||
if group_is_missing:
|
||||
missing.append(item)
|
||||
|
||||
if not missing:
|
||||
self.log("Keine neuen Netzwerke erkannt")
|
||||
return []
|
||||
|
||||
dialog = DetectedNetworksDialog(
|
||||
self.root,
|
||||
missing,
|
||||
self.tenant_vrfs(tenant_id),
|
||||
default_vrf_id,
|
||||
tenant_name,
|
||||
)
|
||||
if dialog.result is None:
|
||||
return None
|
||||
|
||||
for decision in dialog.result:
|
||||
if decision.get("create_vrf"):
|
||||
decision["vrf_id"] = self.get_or_create_tenant_vrf(decision["vrf_name"], tenant_id)
|
||||
decision["create_vrf"] = False
|
||||
self.log(f"Netzwerkplan bestätigt: {len(dialog.result)} neue(s) Netzwerk(e)")
|
||||
return dialog.result
|
||||
|
||||
def open_network_manager(self):
|
||||
if self.nb is None or not self.tenant_map:
|
||||
messagebox.showwarning(
|
||||
@@ -1468,16 +1730,23 @@ class NetBoxVMImporter:
|
||||
return matches
|
||||
|
||||
def sync_prefix_tenant(self, address, tenant_id, prefix_is_detected, default_vrf_id):
|
||||
"""Assign a tenant, choose a VRF, and ask before creating a missing prefix."""
|
||||
"""Assign a tenant and apply the previously confirmed network import plan."""
|
||||
if not hasattr(self, "synced_prefixes"):
|
||||
self.synced_prefixes = set()
|
||||
if not hasattr(self, "prefix_decisions"):
|
||||
self.prefix_decisions = {}
|
||||
if not hasattr(self, "prefix_plans"):
|
||||
self.prefix_plans = []
|
||||
parsed_interface = ipaddress.ip_interface(address)
|
||||
host_address = parsed_interface.ip
|
||||
network = str(parsed_interface.network)
|
||||
vrf_id = default_vrf_id
|
||||
|
||||
for existing_prefix, existing_network, existing_vrf_id in getattr(self, "preexisting_prefixes", []):
|
||||
if host_address in ipaddress.ip_network(existing_network):
|
||||
existing_prefix.update({"tenant": tenant_id})
|
||||
self.synced_prefixes.add((existing_network, existing_vrf_id))
|
||||
self.log(f"Vorhandenes Netzwerk wiederverwendet: {existing_network}")
|
||||
return existing_vrf_id
|
||||
|
||||
for cached_network, cached_vrf in self.synced_prefixes:
|
||||
if host_address in ipaddress.ip_network(cached_network):
|
||||
return cached_vrf
|
||||
@@ -1511,40 +1780,18 @@ class NetBoxVMImporter:
|
||||
self.synced_prefixes.add((existing_network, existing_vrf_id))
|
||||
self.log(f"Vorhandenes Netzwerk wiederverwendet: {existing_network}")
|
||||
return existing_vrf_id
|
||||
decision = self.prefix_decisions.get(cache_key)
|
||||
needs_decision = cache_key not in self.prefix_decisions
|
||||
if decision is not None and host_address not in ipaddress.ip_network(decision["network"]):
|
||||
needs_decision = True
|
||||
if needs_decision:
|
||||
tenant_vrfs = self.tenant_vrfs(tenant_id)
|
||||
tenant_name = next(
|
||||
(
|
||||
name for name, mapped_id in getattr(self, "tenant_map", {}).items()
|
||||
if mapped_id == tenant_id
|
||||
),
|
||||
"Mandant",
|
||||
)
|
||||
dialog = PrefixCreationDialog(
|
||||
self.root,
|
||||
address,
|
||||
detected_prefix=prefix_is_detected,
|
||||
vrfs=tenant_vrfs,
|
||||
default_vrf_id=vrf_id,
|
||||
tenant_name=tenant_name,
|
||||
)
|
||||
self.prefix_decisions[cache_key] = dialog.result
|
||||
decision = self.prefix_decisions[cache_key]
|
||||
if decision is None:
|
||||
self.log(f"Präfix für {host_address} nicht angelegt (Benutzerauswahl)")
|
||||
matching_plans = [
|
||||
decision for decision in self.prefix_plans
|
||||
if host_address in ipaddress.ip_network(decision["network"])
|
||||
]
|
||||
if not matching_plans:
|
||||
self.log(f"Präfix für {host_address} nicht angelegt (Netzwerkplan)")
|
||||
return vrf_id
|
||||
|
||||
network = decision["network"]
|
||||
if decision.get("create_vrf"):
|
||||
decision["vrf_id"] = self.get_or_create_tenant_vrf(
|
||||
decision["vrf_name"],
|
||||
tenant_id,
|
||||
decision = max(
|
||||
matching_plans,
|
||||
key=lambda item: ipaddress.ip_network(item["network"]).prefixlen,
|
||||
)
|
||||
decision["create_vrf"] = False
|
||||
network = decision["network"]
|
||||
vrf_id = decision["vrf_id"]
|
||||
cache_key = (network, vrf_id)
|
||||
selected_candidates = self.find_prefixes_in_vrf(network, vrf_id)
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Reference in New Issue
Block a user