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('&', '&').replace('<', '<').replace('>', '>') 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"{escaped_name}") 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"{escaped_location_name}") 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"{addr_text}") 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( "Connected") else: status_label.set_markup( "Disconnected") 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"{escaped_host_name}" if is_vm else f"{escaped_host_name}" 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"{host.get_ip_display()}") 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)