This commit is contained in:
2025-09-07 23:33:55 +02:00
parent d918f1e497
commit fbacfde9f2
33 changed files with 2626 additions and 1236 deletions

490
main.py
View File

@@ -1,22 +1,27 @@
#!/usr/bin/env python3
from views import ActiveView, InactiveView
from views import ActiveView, InactiveView, LogView
from data_loader import load_customers
from models import Customer
from PIL import Image, ImageDraw
import pystray
import threading
# from services import VPNManager, VPNStatus, VPNConnectionError # Temporarily disabled due to syntax errors
from services import VPNManager, VPNStatus, VPNConnectionError
import sys
from gi.repository import Gtk, Gdk, GLib
import logging
from gi.repository import Gtk, Gdk, GLib, Gio
import gi
gi.require_version('Gtk', '3.0')
class VPNManagerWindow:
vpn_manager: VPNManager
def __init__(self):
self.customers = load_customers()
self.filtered_customers = self.customers.copy()
self.current_location = None # Track user's current location
# VPN manager will be initialized after UI setup
self.vpn_manager = None
# Create main window
self.window = Gtk.Window()
self.window.set_title("VPN Manager")
@@ -24,30 +29,18 @@ class VPNManagerWindow:
self.window.connect("delete-event", self.quit_app_from_close)
self.window.connect("window-state-event", self.on_window_state_event)
# Set up minimal CSS for GNOME-style cards
# Set up minimal CSS for GNOME-style cards
self.setup_css()
# Create UI
self.setup_ui()
self.setup_system_tray()
# Start hidden
self.window.hide()
self.vpn_manager = VPNManager()
def setup_css(self):
"""Minimal CSS for GNOME-style cards"""
css_provider = Gtk.CssProvider()
css = """
.card {
background: @theme_base_color;
border-radius: 8px;
border: 1px solid @borders;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
padding: 16px;
margin: 6px;
}
"""
css_provider.load_from_data(css.encode())
css_provider.load_from_file(Gio.File.new_for_path('style.css'))
# css_provider.load_from_data(css.encode())
# Apply CSS to default screen
screen = Gdk.Screen.get_default()
@@ -72,12 +65,9 @@ class VPNManagerWindow:
main_vbox.set_margin_bottom(12)
self.window.add(main_vbox)
# Current location display
self.current_location_label = Gtk.Label()
self.current_location_label.set_markup("<i>Current location: Not set</i>")
self.current_location_label.set_halign(Gtk.Align.CENTER)
self.current_location_label.set_margin_bottom(8)
main_vbox.pack_start(self.current_location_label, False, False, 0)
# Current location display - enhanced info box
self.location_info_box = self._create_location_info_box()
main_vbox.pack_start(self.location_info_box, False, False, 0)
# Search bar with SearchEntry
self.search_entry = Gtk.SearchEntry()
@@ -91,62 +81,65 @@ class VPNManagerWindow:
self.view_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE)
self.view_stack.set_transition_duration(200)
main_vbox.pack_start(self.view_stack, True, True, 0)
# Get callbacks for views
callbacks = self.get_callbacks()
# Create active view (shown by default)
self.active_view = ActiveView(callbacks)
self.view_stack.add_named(self.active_view.widget, "active")
# Create inactive view (shown when searching)
self.inactive_view = InactiveView(callbacks)
self.view_stack.add_named(self.inactive_view.widget, "inactive")
# Create log section at bottom (collapsible)
self._create_log_section(main_vbox)
# Initialize VPN manager (temporarily disabled due to syntax errors)
# TODO: Fix VPN manager syntax and re-enable
self.vpn_manager = None
self.log_view.log_info(
"VPN manager temporarily disabled for debugging")
self.log_view.log_info("Using mock mode for VPN operations")
# Render initial data
self.render_customers()
def setup_system_tray(self):
# Create a simple icon for the system tray
def create_icon():
# Create a simple network icon
width = height = 64
image = Image.new('RGBA', (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(image)
# Update VPN status from actual connections
self.update_vpn_status()
# Draw a simple network/VPN icon
# Outer circle
draw.ellipse([8, 8, 56, 56], outline=(50, 150, 50), width=4)
# Inner dot
draw.ellipse([26, 26, 38, 38], fill=(50, 150, 50))
# Connection lines
draw.line([32, 16, 32, 24], fill=(50, 150, 50), width=3)
draw.line([32, 40, 32, 48], fill=(50, 150, 50), width=3)
draw.line([16, 32, 24, 32], fill=(50, 150, 50), width=3)
draw.line([40, 32, 48, 32], fill=(50, 150, 50), width=3)
def _setup_logging(self):
"""Set up logging to route VPN manager logs to LogView."""
# Create a custom handler that forwards to our LogView
class LogViewHandler(logging.Handler):
def __init__(self, log_view):
super().__init__()
self.log_view = log_view
return image
def emit(self, record):
try:
msg = self.format(record)
if record.levelno >= logging.ERROR:
self.log_view.log_error(msg)
elif record.levelno >= logging.WARNING:
self.log_view.log_warning(msg)
elif record.levelno >= logging.INFO:
self.log_view.log_info(msg)
else: # DEBUG
self.log_view.log_debug(msg)
except Exception:
self.handleError(record)
# Simple approach: Create tray icon with direct action and minimal menu
self.tray_icon = pystray.Icon(
"VPN Manager",
create_icon(),
"VPN Manager - Double-click to open"
)
# Set up handler for VPN manager logs
handler = LogViewHandler(self.log_view)
handler.setFormatter(logging.Formatter('%(message)s'))
# Set direct click action
self.tray_icon.default_action = self.show_window_from_tray
# Also provide a right-click menu
menu = pystray.Menu(
pystray.MenuItem("Open VPN Manager",
self.show_window_from_tray, default=True),
pystray.MenuItem("Quit", self.quit_app)
)
self.tray_icon.menu = menu
# Start tray icon in separate thread
threading.Thread(target=self.tray_icon.run, daemon=True).start()
# Add handler to VPN manager logger
vpn_logger = logging.getLogger('services.vpn_manager')
vpn_logger.addHandler(handler)
vpn_logger.setLevel(logging.DEBUG)
vpn_logger.propagate = False # Don't send to root logger
def get_callbacks(self):
"""Return callback functions for widget interactions"""
@@ -204,10 +197,12 @@ class VPNManagerWindow:
target_location = customer.get_location_by_name(location.name)
if target_location:
target_location.active = True
self.log_view.log_info(
f"Activated location: {customer.name} - {target_location.name}")
print(
f"Mock: Setting {customer.name} - {target_location.name} as active")
break
# Clear search and return to active view
self.search_entry.set_text("")
self.render_customers()
@@ -219,35 +214,322 @@ class VPNManagerWindow:
if target_location:
target_location.active = False
target_location.connected = False # Disconnect when deactivating
self.log_view.log_info(
f"Deactivated location: {customer.name} - {target_location.name}")
print(
f"Mock: Deactivating {customer.name} - {target_location.name}")
break
self.render_customers()
def set_current_location(self, location, customer_name):
"""Set the user's current location."""
for customer in self.customers:
if customer.name == customer_name:
target_location = customer.get_location_by_name(location.name)
if target_location:
self.current_location = (customer.name, target_location.name)
print(f"Current location set to: {customer.name} - {target_location.name}")
self.current_location = (
customer.name, target_location.name)
self.log_view.log_info(
f"Current location set to: {customer.name} - {target_location.name}")
print(
f"Current location set to: {customer.name} - {target_location.name}")
self.update_current_location_display()
break
def _create_location_info_box(self):
"""Create the enhanced current location info box."""
frame = Gtk.Frame()
frame.get_style_context().add_class("location-info")
frame.set_shadow_type(Gtk.ShadowType.NONE)
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
frame.add(vbox)
# Title row with infrastructure toggle
title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
vbox.pack_start(title_box, False, False, 0)
title_label = Gtk.Label()
title_label.set_markup("<b>📍 Current Location</b>")
title_label.set_halign(Gtk.Align.START)
title_box.pack_start(title_label, False, False, 0)
# Infrastructure toggle button (only shown when location is set)
self.infrastructure_toggle = Gtk.Button()
self.infrastructure_toggle.set_relief(Gtk.ReliefStyle.NONE)
self.infrastructure_toggle.set_can_focus(False)
self.infrastructure_toggle.set_label("")
self.infrastructure_toggle.set_tooltip_text("Show/hide infrastructure")
self.infrastructure_toggle.connect(
"clicked", self._on_infrastructure_toggle)
self.infrastructure_toggle.set_visible(False)
title_box.pack_end(self.infrastructure_toggle, False, False, 0)
# Location details label
self.location_details_label = Gtk.Label()
self.location_details_label.set_markup("<i>Not set</i>")
self.location_details_label.set_halign(Gtk.Align.START)
vbox.pack_start(self.location_details_label, False, False, 0)
# Additional info row (hosts, services, etc.)
self.location_extra_info = Gtk.Label()
self.location_extra_info.set_halign(Gtk.Align.START)
self.location_extra_info.set_visible(False)
vbox.pack_start(self.location_extra_info, False, False, 0)
# Infrastructure section (collapsible)
self.infrastructure_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.infrastructure_box.set_margin_top(8)
self.infrastructure_box.set_visible(False)
vbox.pack_start(self.infrastructure_box, False, False, 0)
# Track infrastructure expanded state
self.infrastructure_expanded = False
return frame
def _create_log_section(self, main_vbox):
"""Create the collapsible log section at the bottom."""
# Log section container
log_container = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=0)
log_container.get_style_context().add_class("log-section")
main_vbox.pack_end(log_container, False, False, 0)
# Log header with toggle button
log_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
log_header.set_margin_start(12)
log_header.set_margin_end(12)
log_header.set_margin_top(8)
log_header.set_margin_bottom(8)
log_container.pack_start(log_header, False, False, 0)
# Toggle button for log visibility
self.log_toggle = Gtk.Button()
self.log_toggle.set_relief(Gtk.ReliefStyle.NONE)
self.log_toggle.set_can_focus(False)
self.log_toggle.set_label("")
self.log_toggle.set_tooltip_text("Show/hide command log")
self.log_toggle.connect("clicked", self._on_log_toggle)
log_header.pack_start(self.log_toggle, False, False, 0)
# Log section label
log_section_label = Gtk.Label()
log_section_label.set_markup("<b>Command Log</b>")
log_section_label.set_halign(Gtk.Align.START)
log_header.pack_start(log_section_label, False, False, 0)
# Create the log view
self.log_view = LogView()
log_container.pack_start(self.log_view.widget, False, False, 0)
# Start with log collapsed
self.log_expanded = False
self.log_view.set_visible(False)
# Log some initial messages
self.log_view.log_info("VPN Manager started")
self.log_view.log_info(f"Loaded {len(self.customers)} customers")
def _on_log_toggle(self, button):
"""Toggle log section visibility."""
self.log_expanded = not self.log_expanded
if self.log_expanded:
self.log_toggle.set_label("")
self.log_view.set_visible(True)
else:
self.log_toggle.set_label("")
self.log_view.set_visible(False)
def update_current_location_display(self):
"""Update the current location display label."""
"""Update the current location display with detailed information."""
if self.current_location:
customer_name, location_name = self.current_location
self.current_location_label.set_markup(
f"<i>📍 Current location: <b>{customer_name} - {location_name}</b></i>"
)
# Find the actual location object
location = None
for customer in self.customers:
if customer.name == customer_name:
location = customer.get_location_by_name(location_name)
if location:
break
if location:
# Main location info
self.location_details_label.set_markup(
f"<b>{customer_name}</b> - {location_name}"
)
# Extra info about the location
host_count = len(location.hosts)
total_hosts = len(location.get_all_hosts_flat())
vpn_type = location.vpn_type.value
extra_text = f"<small>{vpn_type} VPN"
if location.external_addresses:
if len(location.external_addresses) == 1:
extra_text += f" • 🌐 {location.external_addresses[0]}"
else:
extra_text += f" • 🌐 {len(location.external_addresses)} endpoints"
if location.networks:
extra_text += f" • 📡 {len(location.networks)} network{'s' if len(location.networks) > 1 else ''}"
extra_text += f"{host_count} hosts"
if total_hosts > host_count:
extra_text += f" ({total_hosts} total with VMs)"
extra_text += "</small>"
self.location_extra_info.set_markup(extra_text)
self.location_extra_info.set_visible(True)
# Show infrastructure toggle and rebuild infrastructure
self.infrastructure_toggle.set_visible(True)
self._rebuild_infrastructure_display(location)
else:
self.location_details_label.set_markup(
f"<b>{customer_name}</b> - {location_name}"
)
self.location_extra_info.set_visible(False)
self.infrastructure_toggle.set_visible(False)
else:
self.current_location_label.set_markup("<i>Current location: Not set</i>")
self.location_details_label.set_markup("<i>Not set</i>")
self.location_extra_info.set_visible(False)
self.infrastructure_toggle.set_visible(False)
self.infrastructure_box.set_visible(False)
def _rebuild_infrastructure_display(self, location):
"""Rebuild the infrastructure display for the current location."""
# Clear existing infrastructure widgets
for child in self.infrastructure_box.get_children():
child.destroy()
# Add network information if available
if location.networks or location.external_addresses:
network_label = Gtk.Label()
network_label.set_markup("<b>Network Configuration</b>")
network_label.set_halign(Gtk.Align.START)
network_label.set_margin_bottom(4)
self.infrastructure_box.pack_start(network_label, False, False, 0)
# External addresses
if location.external_addresses:
for i, address in enumerate(location.external_addresses):
ext_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
ext_box.set_margin_start(12)
self.infrastructure_box.pack_start(
ext_box, False, False, 0)
label_text = "🌐 <b>External:</b>" if i == 0 else "🌐 <b>Backup:</b>"
ext_label = Gtk.Label()
ext_label.set_markup(
f"<small>{label_text} {address}</small>")
ext_label.set_halign(Gtk.Align.START)
ext_box.pack_start(ext_label, False, False, 0)
# Internal networks
if location.networks:
for network in location.networks:
net_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
net_box.set_margin_start(12)
self.infrastructure_box.pack_start(
net_box, False, False, 0)
net_label = Gtk.Label()
net_label.set_markup(
f"<small>📡 <b>Network:</b> {network}</small>")
net_label.set_halign(Gtk.Align.START)
net_box.pack_start(net_label, False, False, 0)
# Add spacing before infrastructure
if location.hosts:
spacer = Gtk.Box()
spacer.set_size_request(-1, 8)
self.infrastructure_box.pack_start(spacer, False, False, 0)
if not location.hosts:
return
# Add infrastructure label
infra_label = Gtk.Label()
infra_label.set_markup("<b>Infrastructure</b>")
infra_label.set_halign(Gtk.Align.START)
infra_label.set_margin_bottom(4)
self.infrastructure_box.pack_start(infra_label, False, False, 0)
# Add hosts
for host in location.hosts:
host_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
host_box.set_margin_start(12)
self.infrastructure_box.pack_start(host_box, False, False, 0)
# Host type icon
host_type_icons = {
'Linux': '🐧',
'Windows': '🪟',
'Windows Server': '🏢',
'Proxmox': '📦',
'ESXi': '⚙️',
'Router': '📡',
'Switch': '🔀'
}
icon = host_type_icons.get(host.host_type.value, '💻')
# Host info
host_label = Gtk.Label()
service_count = len(host.services)
vm_count = len(host.sub_hosts)
host_text = f"{icon} <b>{host.name}</b> ({host.ip_address})"
if service_count > 0:
host_text += f"{service_count} services"
if vm_count > 0:
host_text += f"{vm_count} VMs"
host_label.set_markup(f"<small>{host_text}</small>")
host_label.set_halign(Gtk.Align.START)
host_box.pack_start(host_label, False, False, 0)
# Add sub-hosts (VMs) if any
if host.sub_hosts:
for vm in host.sub_hosts:
vm_box = Gtk.Box(
orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
vm_box.set_margin_start(24)
self.infrastructure_box.pack_start(vm_box, False, False, 0)
vm_icon = host_type_icons.get(vm.host_type.value, '💻')
vm_service_count = len(vm.services)
vm_text = f"{vm_icon} <i>{vm.name}</i> ({vm.ip_address})"
if vm_service_count > 0:
vm_text += f"{vm_service_count} services"
vm_label = Gtk.Label()
vm_label.set_markup(f"<small>{vm_text}</small>")
vm_label.set_halign(Gtk.Align.START)
vm_box.pack_start(vm_label, False, False, 0)
# Show all widgets (but container might be hidden)
self.infrastructure_box.show_all()
def _on_infrastructure_toggle(self, button):
"""Toggle infrastructure section visibility."""
self.infrastructure_expanded = not self.infrastructure_expanded
if self.infrastructure_expanded:
self.infrastructure_toggle.set_label("")
self.infrastructure_box.set_visible(True)
else:
self.infrastructure_toggle.set_label("")
self.infrastructure_box.set_visible(False)
def filter_customers(self, entry):
search_term = entry.get_text().strip()
# Check for wildcard - show all customers
if search_term == "*":
self.filtered_customers = self.customers.copy()
@@ -279,8 +561,12 @@ class VPNManagerWindow:
# Check hosts and their services in this location
def search_hosts(hosts):
for host in hosts:
# Check IP addresses (search in any of the host's IPs)
ip_match = any(search_term_lower in host_ip.ip_address.lower(
) for host_ip in host.ip_addresses)
if (search_term_lower in host.name.lower() or
search_term_lower in host.ip_address.lower() or
ip_match or
search_term_lower in host.host_type.value.lower() or
search_term_lower in host.description.lower()):
return True
@@ -307,11 +593,34 @@ class VPNManagerWindow:
self.render_customers()
def toggle_connection(self, location):
location.connected = not location.connected
status = "connected to" if location.connected else "disconnected from"
print(f"Mock: {status} {location.name} via {location.vpn_type.value}")
# Use actual VPN manager
if location.connected:
# Disconnect
self.log_view.log_info(f"Disconnecting from {location.name}...")
success = self.vpn_manager.disconnect_vpn(location)
if success:
location.connected = False
self.log_view.log_success(f"Disconnected from {location.name}")
else:
self.log_view.log_error(
f"Failed to disconnect from {location.name}")
else:
# Connect
self.log_view.log_info(
f"Connecting to {location.name} via {location.vpn_type.value}...")
success = self.vpn_manager.connect_vpn(location)
if success:
location.connected = True
self.log_view.log_success(f"Connected to {location.name}")
else:
self.log_view.log_error(
f"Failed to connect to {location.name}")
self.render_customers()
# Update VPN status after connection change
self.update_vpn_status()
def open_service(self, service):
# Get the host IP from context - this would need to be passed properly in a real implementation
print(
@@ -343,6 +652,23 @@ class VPNManagerWindow:
self.quit_app()
return False
def update_vpn_status(self):
"""Update location connection status from actual VPN manager."""
if not self.vpn_manager:
return
# Only update status for active locations to avoid unnecessary nmcli calls
for customer in self.customers:
for location in customer.locations:
if location.active: # Only check active locations
try:
status = self.vpn_manager.get_connection_status(
location)
location.connected = (status == VPNStatus.CONNECTED)
except VPNConnectionError:
# If we can't get status, assume disconnected
location.connected = False
def quit_app(self, _widget=None):
# Stop the tray icon
if hasattr(self, 'tray_icon'):