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

View File

@@ -0,0 +1,399 @@
from utils import IconLoader
from gi.repository import Gtk
import gi
gi.require_version('Gtk', '3.0')
def escape_markup(text: str) -> str:
"""Escape special characters for Pango markup."""
return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
class ActiveCustomerCard:
def __init__(self, customer, callbacks):
self.customer = customer
self.callbacks = callbacks
self.expanded = True # Start expanded by default
self.location_expanded = {} # Track expansion state of each location
# Initialize all locations as expanded
for location in self.customer.locations:
self.location_expanded[location.name] = True
self.widget = self._create_widget()
def _create_widget(self):
# Customer card container
customer_frame = Gtk.Frame()
customer_frame.get_style_context().add_class("card")
customer_frame.set_shadow_type(Gtk.ShadowType.NONE)
customer_vbox = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=4)
customer_frame.add(customer_vbox)
# Customer header row
customer_row = self._create_customer_header()
customer_vbox.pack_start(customer_row, False, False, 0)
# Content container (locations)
self.content_box = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=6)
self.content_box.set_margin_start(8)
self.content_box.set_margin_end(8)
self.content_box.set_margin_bottom(8)
customer_vbox.pack_start(self.content_box, False, False, 0)
# Add location cards
for location in self.customer.locations:
location_card = self._create_location_card(location)
self.content_box.pack_start(location_card, False, False, 0)
return customer_frame
def _create_customer_header(self):
"""Create the customer header row."""
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.set_margin_start(4)
row.set_margin_end(4)
row.set_margin_top(4)
row.set_margin_bottom(2)
# Expand/collapse arrow
self.expand_button = Gtk.Button()
self.expand_button.set_relief(Gtk.ReliefStyle.NONE)
self.expand_button.set_can_focus(False)
self.expand_button.set_size_request(20, 20)
self._update_expand_button()
self.expand_button.connect("clicked", self._on_expand_toggle)
row.pack_start(self.expand_button, False, False, 0)
# Customer name
customer_label = Gtk.Label()
escaped_name = escape_markup(self.customer.name)
customer_label.set_markup(f"<b>{escaped_name}</b>")
customer_label.set_halign(Gtk.Align.START)
row.pack_start(customer_label, True, True, 0)
# Customer service icons (right side)
self._add_customer_service_icons(row)
return row
def _create_location_card(self, location):
"""Create a location card (subcard within customer card)."""
# Location subcard
location_frame = Gtk.Frame()
location_frame.get_style_context().add_class("card")
location_frame.set_shadow_type(Gtk.ShadowType.NONE)
location_vbox = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=2)
location_frame.add(location_vbox)
# Location header row
location_row = self._create_location_header(location)
location_vbox.pack_start(location_row, False, False, 0)
# Hosts container (collapsible)
location_key = location.name
hosts_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
hosts_box.set_margin_start(8)
hosts_box.set_margin_end(4)
hosts_box.set_margin_bottom(4)
# Add hosts for this location
if location.hosts:
for host in location.hosts:
host_row = self._create_host_row(host, location)
hosts_box.pack_start(host_row, False, False, 0)
# Add sub-hosts (VMs)
if host.sub_hosts:
for vm in host.sub_hosts:
vm_row = self._create_host_row(
vm, location, is_vm=True)
hosts_box.pack_start(vm_row, False, False, 0)
location_vbox.pack_start(hosts_box, False, False, 0)
# Store reference to hosts_box for expand/collapse
setattr(location_frame, 'hosts_box', hosts_box)
setattr(location_frame, 'location', location)
# Set initial visibility
expanded = self.location_expanded.get(location_key, True)
hosts_box.set_visible(expanded)
return location_frame
def _create_location_header(self, location):
"""Create the location header row."""
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
row.set_margin_start(4)
row.set_margin_end(4)
row.set_margin_top(4)
row.set_margin_bottom(2)
# Location expand/collapse arrow
expand_btn = Gtk.Button()
expand_btn.set_relief(Gtk.ReliefStyle.NONE)
expand_btn.set_can_focus(False)
expand_btn.set_size_request(20, 20)
# Set initial arrow direction
expanded = self.location_expanded.get(location.name, True)
expand_btn.set_label("" if expanded else "")
# Connect to toggle function
expand_btn.connect(
"clicked", lambda btn: self._toggle_location_expansion(location, btn))
row.pack_start(expand_btn, False, False, 0)
# Location info
location_info = Gtk.Box(
orientation=Gtk.Orientation.VERTICAL, spacing=0)
# Location name
name_label = Gtk.Label()
escaped_location_name = escape_markup(location.name)
name_label.set_markup(f"<b>{escaped_location_name}</b>")
name_label.set_halign(Gtk.Align.START)
location_info.pack_start(name_label, False, False, 0)
# External addresses (small text)
if location.external_addresses:
addr_text = ", ".join(
location.external_addresses[:2]) # Show first 2
if len(location.external_addresses) > 2:
addr_text += f" (+{len(location.external_addresses) - 2} more)"
addr_label = Gtk.Label()
addr_label.set_markup(f"<small><i>{addr_text}</i></small>")
addr_label.set_halign(Gtk.Align.START)
location_info.pack_start(addr_label, False, False, 0)
row.pack_start(location_info, True, True, 0)
# VPN Status
status_label = Gtk.Label()
if location.connected:
status_label.set_markup(
"<span color='#4caf50'><b>Connected</b></span>")
else:
status_label.set_markup(
"<span color='#999'><b>Disconnected</b></span>")
row.pack_start(status_label, False, False, 0)
# Action icons
self._add_location_action_icons(row, location)
return row
def _create_host_row(self, host, location, is_vm=False):
"""Create a host row with aligned IP addresses."""
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
margin = 16 if not is_vm else 32 # VMs are more indented
row.set_margin_start(margin)
row.set_margin_end(4)
row.set_margin_top(1)
row.set_margin_bottom(1)
# Host icon - custom or fallback to Material Icons
icon_widget = IconLoader.get_host_icon_widget(host, size=20)
icon_container = Gtk.Box()
icon_container.set_size_request(24, 24) # Fixed size for alignment
icon_container.set_center_widget(icon_widget)
row.pack_start(icon_container, False, False, 0)
# Host name (fixed width for alignment)
escaped_host_name = escape_markup(host.name)
name_markup = f"<i>{escaped_host_name}</i>" if is_vm else f"<b>{escaped_host_name}</b>"
name_label = Gtk.Label()
name_label.set_markup(name_markup)
name_label.set_halign(Gtk.Align.START)
name_label.set_size_request(150, -1) # Fixed width to align IPs
row.pack_start(name_label, False, False, 0)
# IP address (aligned in middle)
ip_label = Gtk.Label()
ip_label.set_markup(f"<small>{host.get_ip_display()}</small>")
ip_label.set_size_request(120, -1) # Fixed width for alignment
ip_label.set_halign(Gtk.Align.CENTER)
ip_label.set_tooltip_text(", ".join(host.get_all_ips()) if len(
host.ip_addresses) > 1 else host.get_primary_ip())
row.pack_start(ip_label, False, False, 0)
# Spacer to push service icons to the right
spacer = Gtk.Box()
row.pack_start(spacer, True, True, 0)
# Service action icons
self._add_host_service_icons(row, host, location)
return row
def _add_customer_service_icons(self, row):
"""Add customer service icons to the right side."""
# Service type to icon mapping
service_icons = {
'Email & Office': '📧', # O365
'Phone System': '📞', # PBX
'CRM': '👥', # Salesforce
'Email': '📧',
'Office': '📄',
}
# Add icons for each service
for service in self.customer.services[:4]: # Limit to 4 icons
icon = service_icons.get(service.service_type, '🌐')
btn = Gtk.Button()
btn.set_label(icon)
btn.set_relief(Gtk.ReliefStyle.NONE)
btn.set_can_focus(False)
btn.set_size_request(24, 24)
btn.set_tooltip_text(f"Open {service.name}")
btn.connect("clicked", lambda b,
s=service: self.callbacks['open_customer_service'](s))
row.pack_start(btn, False, False, 0)
# Menu button (always last)
menu_btn = self._create_menu_button()
row.pack_start(menu_btn, False, False, 0)
def _add_location_action_icons(self, row, location):
"""Add location action icons."""
# Connection toggle
connect_icon = "🔌" if not location.connected else "🔓"
connect_btn = Gtk.Button()
connect_btn.set_label(connect_icon)
connect_btn.set_relief(Gtk.ReliefStyle.NONE)
connect_btn.set_can_focus(False)
connect_btn.set_size_request(24, 24)
tooltip = "Connect to VPN" if not location.connected else "Disconnect VPN"
connect_btn.set_tooltip_text(tooltip)
connect_btn.connect(
"clicked", lambda b: self.callbacks['toggle_connection'](location))
row.pack_start(connect_btn, False, False, 0)
# Refresh/reload
refresh_btn = Gtk.Button()
refresh_btn.set_label("🔄")
refresh_btn.set_relief(Gtk.ReliefStyle.NONE)
refresh_btn.set_can_focus(False)
refresh_btn.set_size_request(24, 24)
refresh_btn.set_tooltip_text("Refresh connection")
row.pack_start(refresh_btn, False, False, 0)
# Menu
menu_btn = self._create_menu_button()
row.pack_start(menu_btn, False, False, 0)
def _add_host_service_icons(self, row, host, location):
"""Add host service icons with reachability check."""
# Service type to Material Icons mapping
# Icon names from: https://fonts.google.com/icons
service_icons = {
'SSH': 'terminal', # Terminal icon for SSH
'Web GUI': 'language', # Globe icon for web
'RDP': 'desktop_windows', # Desktop icon for RDP
'VNC': 'monitor', # Monitor icon for VNC
'SMB': 'folder_shared', # Shared folder for SMB
'Database': 'storage', # Database/storage icon
'FTP': 'cloud_upload' # Upload icon for FTP
}
# Add icons for services
for service in host.services[:3]: # Limit to 3 service icons
# Default to settings icon
icon = service_icons.get(service.service_type.value, 'settings')
# Check if service is reachable
is_reachable = location.is_service_reachable(host, service)
is_external = location.get_external_url_for_service(
host, service) is not None
btn = Gtk.Button()
btn.set_label(icon) # Material Icons uses ligatures
btn.set_relief(Gtk.ReliefStyle.NONE)
btn.set_can_focus(False)
btn.set_size_request(24, 24)
# Apply color styling based on reachability
if is_reachable:
# Green for accessible
btn.get_style_context().add_class("service-icon-accessible")
if is_external and not location.connected:
external_url = location.get_external_url_for_service(
host, service)
btn.set_tooltip_text(
f"{service.service_type.value}: {service.name}\nExternal: {external_url}")
else:
btn.set_tooltip_text(
f"{service.service_type.value}: {service.name}")
else:
# Red for not accessible
btn.get_style_context().add_class("service-icon-inaccessible")
btn.set_tooltip_text(
f"{service.service_type.value}: {service.name}\nNot reachable (VPN disconnected)")
# Enable/disable based on reachability
btn.set_sensitive(is_reachable)
# Connect click handler only if reachable
if is_reachable:
btn.connect("clicked", lambda b,
s=service: self.callbacks['open_service'](s))
row.pack_start(btn, False, False, 0)
# Menu button
menu_btn = self._create_menu_button()
row.pack_start(menu_btn, False, False, 0)
def _create_menu_button(self):
"""Create a menu button with empty popup."""
menu_btn = Gtk.MenuButton()
menu_btn.set_label("") # Three dots menu
menu_btn.set_relief(Gtk.ReliefStyle.NONE)
menu_btn.set_can_focus(False)
menu_btn.set_size_request(24, 24)
# Create empty menu for now
menu = Gtk.Menu()
placeholder_item = Gtk.MenuItem(label="(Empty menu)")
placeholder_item.set_sensitive(False)
menu.append(placeholder_item)
menu.show_all()
menu_btn.set_popup(menu)
return menu_btn
def _update_expand_button(self):
"""Update the expand button arrow direction."""
if self.expanded:
self.expand_button.set_label("")
else:
self.expand_button.set_label("")
def _toggle_location_expansion(self, location, button):
"""Toggle the expansion state of a specific location."""
location_key = location.name
current_state = self.location_expanded.get(location_key, True)
new_state = not current_state
self.location_expanded[location_key] = new_state
# Update button arrow
button.set_label("" if new_state else "")
# Find the location card and toggle its hosts box visibility
for widget in self.content_box.get_children():
if hasattr(widget, 'location') and widget.location.name == location_key:
hosts_box = getattr(widget, 'hosts_box', None)
if hosts_box:
hosts_box.set_visible(new_state)
break
def _on_expand_toggle(self, button):
"""Toggle the expanded state."""
self.expanded = not self.expanded
self._update_expand_button()
self.content_box.set_visible(self.expanded)