mirror of
https://github.com/grandir66/UnifiMultimirror.git
synced 2026-10-02 15:24:42 +02:00
- TUI application (Textual) for interactive port selection - CLI application (Rich) as alternative interface - SSH manager for device connection - Config parser for running-config, interface status, port-channel - Script generator for Bash, Python, and command files - Support for multiple source ports and LAG interfaces Co-authored-by: Cursor <cursoragent@cursor.com>
824 lines
27 KiB
Python
824 lines
27 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Textual-based TUI Application for Ubiquiti Port Mirroring Manager.
|
|
Full-screen application with keyboard navigation and interactive selection.
|
|
"""
|
|
|
|
from textual.app import App, ComposeResult
|
|
from textual.containers import Container, Horizontal, Vertical, ScrollableContainer
|
|
from textual.widgets import (
|
|
Header, Footer, Static, Button, Label, Input,
|
|
DataTable, TabbedContent, TabPane, Checkbox, Rule
|
|
)
|
|
from textual.binding import Binding
|
|
from textual.screen import Screen, ModalScreen
|
|
from textual.message import Message
|
|
from textual import work
|
|
from textual import events
|
|
from rich.text import Text
|
|
|
|
from typing import Optional, List, Set, Tuple
|
|
from pathlib import Path
|
|
|
|
from ssh_manager import SSHManager
|
|
from config_parser import ConfigParser, SwitchConfig
|
|
|
|
|
|
# =============================================================================
|
|
# Login Screen
|
|
# =============================================================================
|
|
|
|
class LoginScreen(ModalScreen):
|
|
"""Modal screen for SSH login."""
|
|
|
|
BINDINGS = [
|
|
Binding("escape", "cancel", "Annulla"),
|
|
]
|
|
|
|
CSS = """
|
|
LoginScreen {
|
|
align: center middle;
|
|
}
|
|
|
|
#login-container {
|
|
width: 60;
|
|
height: auto;
|
|
border: thick $accent;
|
|
background: $surface;
|
|
padding: 1 2;
|
|
}
|
|
|
|
#login-title {
|
|
text-align: center;
|
|
text-style: bold;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
.login-label {
|
|
margin-top: 1;
|
|
}
|
|
|
|
.login-input {
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
#login-buttons {
|
|
margin-top: 1;
|
|
align: center middle;
|
|
}
|
|
|
|
#login-buttons Button {
|
|
margin: 0 1;
|
|
}
|
|
|
|
#login-error {
|
|
color: $error;
|
|
text-align: center;
|
|
margin-top: 1;
|
|
}
|
|
"""
|
|
|
|
def __init__(self, saved_ip: str = ""):
|
|
super().__init__()
|
|
self.saved_ip = saved_ip
|
|
self.result: Optional[Tuple[str, str]] = None
|
|
|
|
def compose(self) -> ComposeResult:
|
|
with Container(id="login-container"):
|
|
yield Label("CONNESSIONE AL DISPOSITIVO", id="login-title")
|
|
yield Rule()
|
|
yield Label("IP del dispositivo:", classes="login-label")
|
|
yield Input(value=self.saved_ip, placeholder="192.168.1.1", id="ip-input", classes="login-input")
|
|
yield Label("Password (utente: admin):", classes="login-label")
|
|
yield Input(placeholder="password", password=True, id="password-input", classes="login-input")
|
|
yield Label("", id="login-error")
|
|
with Horizontal(id="login-buttons"):
|
|
yield Button("Connetti", variant="primary", id="btn-connect")
|
|
yield Button("Esci", variant="default", id="btn-cancel")
|
|
|
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
if event.button.id == "btn-connect":
|
|
ip = self.query_one("#ip-input", Input).value.strip()
|
|
password = self.query_one("#password-input", Input).value
|
|
|
|
if not ip:
|
|
self.query_one("#login-error", Label).update("IP non valido")
|
|
return
|
|
if not password:
|
|
self.query_one("#login-error", Label).update("Password non valida")
|
|
return
|
|
|
|
self.result = (ip, password)
|
|
self.dismiss(self.result)
|
|
|
|
elif event.button.id == "btn-cancel":
|
|
self.dismiss(None)
|
|
|
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
# Press Enter to submit
|
|
if event.input.id == "password-input":
|
|
self.query_one("#btn-connect", Button).press()
|
|
elif event.input.id == "ip-input":
|
|
self.query_one("#password-input", Input).focus()
|
|
|
|
def action_cancel(self) -> None:
|
|
self.dismiss(None)
|
|
|
|
|
|
# =============================================================================
|
|
# Port Selection Screen (for mirroring configuration)
|
|
# =============================================================================
|
|
|
|
class PortSelectionScreen(Screen):
|
|
"""Full-screen port selection with keyboard navigation."""
|
|
|
|
BINDINGS = [
|
|
Binding("up", "move_up", "Su"),
|
|
Binding("down", "move_down", "Giù"),
|
|
Binding("space", "toggle_select", "Seleziona"),
|
|
Binding("enter", "toggle_select", "Seleziona"),
|
|
Binding("a", "select_all", "Tutti"),
|
|
Binding("n", "select_none", "Nessuno"),
|
|
Binding("d", "set_destination", "Destination"),
|
|
Binding("escape", "cancel", "Annulla"),
|
|
Binding("f10", "confirm", "Conferma"),
|
|
]
|
|
|
|
CSS = """
|
|
PortSelectionScreen {
|
|
layout: vertical;
|
|
}
|
|
|
|
#port-table-container {
|
|
height: 1fr;
|
|
border: solid $primary;
|
|
margin: 1;
|
|
}
|
|
|
|
#selection-info {
|
|
height: 5;
|
|
border: solid $accent;
|
|
margin: 0 1 1 1;
|
|
padding: 0 1;
|
|
}
|
|
|
|
#info-destination {
|
|
color: $warning;
|
|
}
|
|
|
|
#info-sources {
|
|
color: $success;
|
|
}
|
|
|
|
.highlight-row {
|
|
background: $accent;
|
|
}
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
ports: List[Tuple[str, str, str, int, str]], # (id, desc, state, pvid, tagged)
|
|
current_dest: Optional[str],
|
|
current_sources: Set[str]
|
|
):
|
|
super().__init__()
|
|
self.ports = ports
|
|
self.destination = current_dest
|
|
self.sources = current_sources.copy()
|
|
self.cursor_row = 0
|
|
self.result: Optional[Tuple[str, List[str]]] = None
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Header()
|
|
|
|
with Container(id="port-table-container"):
|
|
table = DataTable(id="port-table", cursor_type="row")
|
|
yield table
|
|
|
|
with Container(id="selection-info"):
|
|
yield Label("DESTINATION: (nessuna)", id="info-destination")
|
|
yield Label("SOURCES: 0 porte selezionate", id="info-sources")
|
|
yield Label("[D]=Destination [SPAZIO]=Toggle [A]=Tutti [N]=Nessuno [F10]=Conferma [ESC]=Annulla", id="info-help")
|
|
|
|
yield Footer()
|
|
|
|
def on_mount(self) -> None:
|
|
"""Initialize the table."""
|
|
table = self.query_one("#port-table", DataTable)
|
|
|
|
# Add columns
|
|
table.add_column("", width=3, key="check")
|
|
table.add_column("Porta", width=8, key="port")
|
|
table.add_column("Descrizione", width=28, key="desc")
|
|
table.add_column("Stato", width=6, key="state")
|
|
table.add_column("PVID", width=5, key="pvid")
|
|
table.add_column("Tagged VLANs", width=24, key="tagged")
|
|
table.add_column("Ruolo", width=14, key="role")
|
|
|
|
# Add rows
|
|
for port_id, desc, state, pvid, tagged in self.ports:
|
|
self._add_port_row(table, port_id, desc, state, pvid, tagged)
|
|
|
|
# Update info
|
|
self._update_info()
|
|
|
|
# Focus table
|
|
table.focus()
|
|
|
|
def _add_port_row(self, table: DataTable, port_id: str, desc: str, state: str, pvid: int, tagged: str) -> None:
|
|
"""Add a row to the table."""
|
|
# Checkbox
|
|
if port_id == self.destination:
|
|
check = Text("◀◀", style="bold magenta")
|
|
role = Text("DESTINATION", style="bold magenta")
|
|
elif port_id in self.sources:
|
|
check = Text("[X]", style="bold cyan")
|
|
role = Text("SOURCE", style="bold cyan")
|
|
else:
|
|
check = Text("[ ]", style="dim")
|
|
role = Text("-", style="dim")
|
|
|
|
# State
|
|
state_text = Text("UP", style="bold green") if state == "Up" else Text("DOWN", style="dim")
|
|
|
|
table.add_row(
|
|
check,
|
|
port_id,
|
|
desc[:28],
|
|
state_text,
|
|
str(pvid),
|
|
tagged[:24] if tagged else "-",
|
|
role,
|
|
key=port_id
|
|
)
|
|
|
|
def _refresh_table(self) -> None:
|
|
"""Refresh all rows in the table."""
|
|
table = self.query_one("#port-table", DataTable)
|
|
|
|
for port_id, desc, state, pvid, tagged in self.ports:
|
|
# Checkbox
|
|
if port_id == self.destination:
|
|
check = Text("◀◀", style="bold magenta")
|
|
role = Text("DESTINATION", style="bold magenta")
|
|
elif port_id in self.sources:
|
|
check = Text("[X]", style="bold cyan")
|
|
role = Text("SOURCE", style="bold cyan")
|
|
else:
|
|
check = Text("[ ]", style="dim")
|
|
role = Text("-", style="dim")
|
|
|
|
state_text = Text("UP", style="bold green") if state == "Up" else Text("DOWN", style="dim")
|
|
|
|
# Update row
|
|
table.update_cell(port_id, "check", check)
|
|
table.update_cell(port_id, "role", role)
|
|
|
|
self._update_info()
|
|
|
|
def _update_info(self) -> None:
|
|
"""Update the info panel."""
|
|
dest_label = self.query_one("#info-destination", Label)
|
|
src_label = self.query_one("#info-sources", Label)
|
|
|
|
if self.destination:
|
|
# Find description
|
|
desc = ""
|
|
for p in self.ports:
|
|
if p[0] == self.destination:
|
|
desc = p[1]
|
|
break
|
|
dest_label.update(f"DESTINATION: {self.destination} ({desc})")
|
|
else:
|
|
dest_label.update("DESTINATION: (nessuna - premi D per impostare)")
|
|
|
|
src_label.update(f"SOURCES: {len(self.sources)} porte selezionate")
|
|
|
|
def action_move_up(self) -> None:
|
|
table = self.query_one("#port-table", DataTable)
|
|
table.action_cursor_up()
|
|
|
|
def action_move_down(self) -> None:
|
|
table = self.query_one("#port-table", DataTable)
|
|
table.action_cursor_down()
|
|
|
|
def action_toggle_select(self) -> None:
|
|
"""Toggle selection of current row."""
|
|
table = self.query_one("#port-table", DataTable)
|
|
|
|
if table.cursor_row is None:
|
|
return
|
|
|
|
# Get port_id from our ports list using cursor position
|
|
if table.cursor_row >= len(self.ports):
|
|
return
|
|
|
|
port_id = self.ports[table.cursor_row][0]
|
|
|
|
if port_id == self.destination:
|
|
# Can't select destination as source
|
|
self.notify("Non puoi selezionare la destination come source", severity="warning")
|
|
return
|
|
|
|
if port_id in self.sources:
|
|
self.sources.remove(port_id)
|
|
else:
|
|
self.sources.add(port_id)
|
|
|
|
self._refresh_table()
|
|
|
|
def action_set_destination(self) -> None:
|
|
"""Set current row as destination."""
|
|
table = self.query_one("#port-table", DataTable)
|
|
|
|
if table.cursor_row is None:
|
|
return
|
|
|
|
# Get port_id from our ports list using cursor position
|
|
if table.cursor_row >= len(self.ports):
|
|
return
|
|
|
|
port_id = self.ports[table.cursor_row][0]
|
|
|
|
# Remove from sources if was selected
|
|
self.sources.discard(port_id)
|
|
self.destination = port_id
|
|
|
|
self._refresh_table()
|
|
self.notify(f"Destination impostata: {port_id}", severity="information")
|
|
|
|
def action_select_all(self) -> None:
|
|
"""Select all ports as source (except destination)."""
|
|
for port_id, _, _, _, _ in self.ports:
|
|
if port_id != self.destination:
|
|
self.sources.add(port_id)
|
|
self._refresh_table()
|
|
|
|
def action_select_none(self) -> None:
|
|
"""Deselect all sources."""
|
|
self.sources.clear()
|
|
self._refresh_table()
|
|
|
|
def action_cancel(self) -> None:
|
|
"""Cancel and return to main."""
|
|
self.app.pop_screen()
|
|
|
|
def action_confirm(self) -> None:
|
|
"""Confirm selection."""
|
|
if not self.destination:
|
|
self.notify("Devi selezionare una porta DESTINATION (premi D)", severity="error")
|
|
return
|
|
|
|
if not self.sources:
|
|
self.notify("Devi selezionare almeno una porta SOURCE", severity="error")
|
|
return
|
|
|
|
self.result = (self.destination, list(self.sources))
|
|
self.dismiss(self.result)
|
|
|
|
|
|
# =============================================================================
|
|
# Main Application
|
|
# =============================================================================
|
|
|
|
class PortMirroringApp(App):
|
|
"""Main Textual application for Port Mirroring Manager."""
|
|
|
|
TITLE = "Ubiquiti Port Mirroring Manager"
|
|
|
|
CSS = """
|
|
Screen {
|
|
background: $surface;
|
|
}
|
|
|
|
#main-container {
|
|
height: 1fr;
|
|
}
|
|
|
|
#status-bar {
|
|
height: 3;
|
|
dock: bottom;
|
|
background: $primary-background;
|
|
padding: 0 1;
|
|
}
|
|
|
|
#connection-status {
|
|
color: $success;
|
|
}
|
|
|
|
.port-table {
|
|
height: 1fr;
|
|
}
|
|
|
|
#tab-ports DataTable {
|
|
height: 1fr;
|
|
}
|
|
|
|
#tab-mirroring {
|
|
padding: 1;
|
|
}
|
|
|
|
#mirror-info {
|
|
height: auto;
|
|
border: solid $accent;
|
|
padding: 1;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
#mirror-dest {
|
|
color: $warning;
|
|
text-style: bold;
|
|
margin-bottom: 1;
|
|
}
|
|
|
|
#mirror-sources-title {
|
|
color: $success;
|
|
text-style: bold;
|
|
}
|
|
|
|
#action-buttons {
|
|
height: auto;
|
|
margin-top: 1;
|
|
}
|
|
|
|
#action-buttons Button {
|
|
margin: 0 1 0 0;
|
|
}
|
|
|
|
.info-panel {
|
|
height: auto;
|
|
border: solid $primary;
|
|
padding: 1;
|
|
margin: 1;
|
|
}
|
|
|
|
#device-info {
|
|
height: auto;
|
|
dock: top;
|
|
background: $primary;
|
|
color: $text;
|
|
padding: 0 1;
|
|
text-align: center;
|
|
}
|
|
"""
|
|
|
|
BINDINGS = [
|
|
Binding("q", "quit", "Esci"),
|
|
Binding("r", "reload", "Ricarica"),
|
|
Binding("c", "configure", "Configura"),
|
|
Binding("g", "generate", "Genera Script"),
|
|
Binding("f5", "apply", "Applica"),
|
|
]
|
|
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.ssh_manager = SSHManager()
|
|
self.parser = ConfigParser()
|
|
self.is_connected = False
|
|
self.device_ip = ""
|
|
|
|
# Pending configuration
|
|
self.pending_dest: Optional[str] = None
|
|
self.pending_sources: List[str] = []
|
|
|
|
def compose(self) -> ComposeResult:
|
|
yield Header()
|
|
yield Label("Non connesso", id="device-info")
|
|
|
|
with TabbedContent(id="main-tabs"):
|
|
with TabPane("Stato Porte", id="tab-ports"):
|
|
yield DataTable(id="ports-table", cursor_type="row")
|
|
|
|
with TabPane("Mirroring", id="tab-mirroring"):
|
|
with Container(id="mirror-info"):
|
|
yield Label("DESTINATION: -", id="mirror-dest")
|
|
yield Label("SOURCE PORTS:", id="mirror-sources-title")
|
|
yield DataTable(id="sources-table", cursor_type="row")
|
|
|
|
with Horizontal(id="action-buttons"):
|
|
yield Button("Configura Mirroring", variant="primary", id="btn-configure")
|
|
yield Button("Genera Script", variant="default", id="btn-generate")
|
|
yield Button("Applica al Device", variant="warning", id="btn-apply")
|
|
|
|
yield Footer()
|
|
|
|
def on_mount(self) -> None:
|
|
"""Called when app is mounted."""
|
|
# Setup tables
|
|
self._setup_ports_table()
|
|
self._setup_sources_table()
|
|
|
|
# Show login (use call_later to avoid blocking)
|
|
self.call_later(self._show_login_screen)
|
|
|
|
def _setup_ports_table(self) -> None:
|
|
"""Setup the ports table columns."""
|
|
table = self.query_one("#ports-table", DataTable)
|
|
table.add_column("Porta", width=8, key="port")
|
|
table.add_column("Descrizione", width=28, key="desc")
|
|
table.add_column("Stato", width=6, key="state")
|
|
table.add_column("PVID", width=5, key="pvid")
|
|
table.add_column("Tagged VLANs", width=26, key="tagged")
|
|
table.add_column("Mirroring", width=14, key="mirror")
|
|
|
|
def _setup_sources_table(self) -> None:
|
|
"""Setup the sources table columns."""
|
|
table = self.query_one("#sources-table", DataTable)
|
|
table.add_column("Porta", width=10, key="port")
|
|
table.add_column("Descrizione", width=32, key="desc")
|
|
table.add_column("Stato", width=8, key="state")
|
|
|
|
def _show_login_screen(self) -> None:
|
|
"""Show login screen."""
|
|
saved_ip = ""
|
|
if self.ssh_manager.config_exists():
|
|
success, _ = self.ssh_manager.load_config()
|
|
if success:
|
|
saved_ip = self.ssh_manager.hostname
|
|
|
|
self.push_screen(LoginScreen(saved_ip), callback=self._on_login_result)
|
|
|
|
def _on_login_result(self, result: Optional[Tuple[str, str]]) -> None:
|
|
"""Handle login result."""
|
|
if result is None:
|
|
self.exit()
|
|
return
|
|
|
|
ip, password = result
|
|
self._do_connect(ip, password)
|
|
|
|
@work(exclusive=True, thread=True)
|
|
def _do_connect(self, ip: str, password: str) -> None:
|
|
"""Connect to device (runs in worker thread)."""
|
|
self.notify(f"Connessione a {ip}...", severity="information")
|
|
|
|
# Save and load config
|
|
self.ssh_manager.create_config(ip, "admin", password, 22)
|
|
success, msg = self.ssh_manager.load_config()
|
|
|
|
if not success:
|
|
self.notify(f"Errore: {msg}", severity="error")
|
|
self.call_from_thread(self._show_login_screen)
|
|
return
|
|
|
|
# Connect
|
|
success, msg = self.ssh_manager.connect()
|
|
|
|
if not success:
|
|
self.notify(f"Connessione fallita: {msg}", severity="error")
|
|
self.call_from_thread(self._show_login_screen)
|
|
return
|
|
|
|
self.is_connected = True
|
|
self.device_ip = ip
|
|
|
|
# Update header (must be called from main thread)
|
|
self.call_from_thread(self._update_device_info, f"Connesso a {ip} (admin)")
|
|
|
|
self.notify("Connesso! Caricamento configurazione...", severity="information")
|
|
|
|
# Load config
|
|
self._load_config_sync()
|
|
|
|
def _update_device_info(self, text: str) -> None:
|
|
"""Update device info label."""
|
|
self.query_one("#device-info", Label).update(text)
|
|
|
|
def _load_config_sync(self) -> None:
|
|
"""Load configuration synchronously (called from worker thread)."""
|
|
if not self.is_connected:
|
|
self.notify("Non connesso", severity="error")
|
|
return
|
|
|
|
try:
|
|
# Get running config
|
|
running_config = self.ssh_manager.get_running_config()
|
|
self.parser.parse_running_config(running_config)
|
|
|
|
# Get interface status
|
|
iface_status = self.ssh_manager.get_interfaces_status()
|
|
self.parser.parse_interfaces_status(iface_status)
|
|
|
|
# Get port-channel
|
|
pc_brief = self.ssh_manager.get_port_channel_brief()
|
|
self.parser.parse_port_channel_brief(pc_brief)
|
|
|
|
# Update device info
|
|
hostname = self.parser.config.system_info.hostname or self.device_ip
|
|
self.call_from_thread(self._update_device_info, f"{hostname} ({self.device_ip})")
|
|
|
|
# Refresh tables (must be called from main thread)
|
|
self.call_from_thread(self._refresh_ports_table)
|
|
self.call_from_thread(self._refresh_mirroring_info)
|
|
|
|
self.notify("Configurazione caricata", severity="information")
|
|
|
|
except Exception as e:
|
|
self.notify(f"Errore: {e}", severity="error")
|
|
|
|
@work(exclusive=True, thread=True)
|
|
def _reload_config_worker(self) -> None:
|
|
"""Reload configuration in worker thread."""
|
|
self._load_config_sync()
|
|
|
|
def _refresh_ports_table(self) -> None:
|
|
"""Refresh the ports table."""
|
|
table = self.query_one("#ports-table", DataTable)
|
|
table.clear()
|
|
|
|
dest = self.parser.get_mirroring_destination()
|
|
sources = set(self.parser.get_mirroring_sources())
|
|
|
|
for port_id in self.parser.get_physical_ports():
|
|
iface = self.parser.config.interfaces.get(port_id)
|
|
if not iface:
|
|
continue
|
|
|
|
# State
|
|
state = Text("UP", style="bold green") if iface.link_state == "Up" else Text("DOWN", style="dim")
|
|
|
|
# Tagged VLANs
|
|
tagged = ConfigParser.format_vlan_list(iface.tagged_vlans)
|
|
if len(tagged) > 26:
|
|
tagged = tagged[:23] + "..."
|
|
|
|
# Mirroring
|
|
if port_id == dest:
|
|
mirror = Text("◀◀ DEST", style="bold magenta")
|
|
elif port_id in sources:
|
|
mirror = Text("▶▶ SRC", style="bold cyan")
|
|
else:
|
|
mirror = Text("-", style="dim")
|
|
|
|
table.add_row(
|
|
port_id,
|
|
iface.description[:28] if iface.description else "",
|
|
state,
|
|
str(iface.pvid),
|
|
tagged or "-",
|
|
mirror,
|
|
key=port_id
|
|
)
|
|
|
|
def _refresh_mirroring_info(self) -> None:
|
|
"""Refresh mirroring info panel."""
|
|
dest = self.parser.get_mirroring_destination()
|
|
sources = self.parser.get_mirroring_sources()
|
|
|
|
# Destination
|
|
dest_label = self.query_one("#mirror-dest", Label)
|
|
if dest:
|
|
iface = self.parser.config.interfaces.get(dest)
|
|
desc = iface.description if iface else ""
|
|
dest_label.update(f"DESTINATION: {dest} ({desc})")
|
|
else:
|
|
dest_label.update("DESTINATION: (non configurata)")
|
|
|
|
# Sources table
|
|
table = self.query_one("#sources-table", DataTable)
|
|
table.clear()
|
|
|
|
for src in sorted(sources, key=lambda x: (int(x.split("/")[0]), int(x.split("/")[1]))):
|
|
iface = self.parser.config.interfaces.get(src)
|
|
if iface:
|
|
state = "UP" if iface.link_state == "Up" else "DOWN"
|
|
desc = iface.description or ""
|
|
else:
|
|
state = "-"
|
|
desc = ""
|
|
|
|
table.add_row(src, desc[:32], state, key=src)
|
|
|
|
# Update title
|
|
self.query_one("#mirror-sources-title", Label).update(f"SOURCE PORTS ({len(sources)}):")
|
|
|
|
def action_reload(self) -> None:
|
|
"""Reload configuration."""
|
|
self._reload_config_worker()
|
|
|
|
def action_configure(self) -> None:
|
|
"""Open port selection screen."""
|
|
if not self.is_connected:
|
|
self.notify("Non connesso", severity="error")
|
|
return
|
|
|
|
# Prepare port list
|
|
ports = []
|
|
for port_id in self.parser.get_physical_ports():
|
|
iface = self.parser.config.interfaces.get(port_id)
|
|
if iface:
|
|
tagged = ConfigParser.format_vlan_list(iface.tagged_vlans)
|
|
ports.append((port_id, iface.description or "", iface.link_state, iface.pvid, tagged))
|
|
|
|
# Add LAGs
|
|
for lag_id in self.parser.get_lag_ports():
|
|
lag = self.parser.config.lags.get(lag_id)
|
|
if lag and (lag.member_ports or lag.link_state == "Up"):
|
|
desc = f"LAG {lag.name} ({','.join(lag.member_ports)})"
|
|
ports.append((lag_id, desc, lag.link_state, 1, ""))
|
|
|
|
current_dest = self.parser.get_mirroring_destination()
|
|
current_sources = set(self.parser.get_mirroring_sources())
|
|
|
|
# Show selection screen with callback
|
|
self.push_screen(
|
|
PortSelectionScreen(ports, current_dest, current_sources),
|
|
callback=self._on_configure_result
|
|
)
|
|
|
|
def _on_configure_result(self, result: Optional[Tuple[str, List[str]]]) -> None:
|
|
"""Handle configuration result."""
|
|
if result:
|
|
dest, sources = result
|
|
self.pending_dest = dest
|
|
self.pending_sources = sources
|
|
self.notify(f"Configurazione salvata: {dest} <- {len(sources)} sources", severity="information")
|
|
|
|
def action_generate(self) -> None:
|
|
"""Generate configuration script."""
|
|
from script_generator import ScriptGenerator
|
|
|
|
dest = self.pending_dest or self.parser.get_mirroring_destination()
|
|
sources = self.pending_sources or self.parser.get_mirroring_sources()
|
|
|
|
if not dest:
|
|
self.notify("Nessuna configurazione da generare", severity="warning")
|
|
return
|
|
|
|
# Generate commands
|
|
commands = self._generate_commands(dest, sources)
|
|
|
|
# Generate scripts
|
|
generator = ScriptGenerator()
|
|
paths = generator.generate_all(
|
|
commands=commands,
|
|
device_ip=self.device_ip,
|
|
username="admin",
|
|
config=self.parser.config,
|
|
description=f"Mirroring: {dest} <- {len(sources)} sources"
|
|
)
|
|
|
|
self.notify(f"Script generati in: {paths['bash'].parent}", severity="information")
|
|
|
|
def action_apply(self) -> None:
|
|
"""Apply configuration to device."""
|
|
if not self.is_connected:
|
|
self.notify("Non connesso", severity="error")
|
|
return
|
|
|
|
dest = self.pending_dest
|
|
sources = self.pending_sources
|
|
|
|
if not dest or not sources:
|
|
self.notify("Nessuna configurazione da applicare. Usa prima 'Configura'", severity="warning")
|
|
return
|
|
|
|
self._apply_config_worker(dest, sources)
|
|
|
|
@work(exclusive=True, thread=True)
|
|
def _apply_config_worker(self, dest: str, sources: List[str]) -> None:
|
|
"""Apply configuration in worker thread."""
|
|
commands = self._generate_commands(dest, sources)
|
|
|
|
self.notify("Applicazione in corso...", severity="information")
|
|
|
|
success, output = self.ssh_manager.apply_mirroring_config(commands)
|
|
|
|
if success:
|
|
self.notify("Configurazione applicata!", severity="information")
|
|
self.pending_dest = None
|
|
self.pending_sources = []
|
|
self._load_config_sync()
|
|
else:
|
|
self.notify(f"Errore: {output}", severity="error")
|
|
|
|
def _generate_commands(self, dest: str, sources: List[str]) -> List[str]:
|
|
"""Generate mirroring commands."""
|
|
commands = [f"no monitor session 1"]
|
|
commands.append(f"monitor session 1 destination interface {dest}")
|
|
|
|
for src in sorted(sources, key=lambda x: (int(x.split("/")[0]), int(x.split("/")[1]))):
|
|
commands.append(f"monitor session 1 source interface {src}")
|
|
|
|
commands.append("monitor session 1 mode")
|
|
return commands
|
|
|
|
def action_quit(self) -> None:
|
|
"""Quit application."""
|
|
if self.is_connected:
|
|
self.ssh_manager.disconnect()
|
|
self.exit()
|
|
|
|
|
|
# =============================================================================
|
|
# Entry Point
|
|
# =============================================================================
|
|
|
|
def main():
|
|
app = PortMirroringApp()
|
|
app.run()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|