diff --git a/CLAUDE.md b/CLAUDE.md
index c1a5b49..b186c1f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -18,62 +18,90 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- This project uses `uv` for Python package management
- `uv sync` - Install dependencies from pyproject.toml
- Python 3.13+ required
+- Dependencies: PyGObject (GTK3), pystray, Pillow
## Code Architecture
### Core Components
**main.py** - Main GUI application entry point
-- `VPNManagerWindow` class: Primary tkinter-based GUI application
+- `VPNManagerWindow` class: Primary PyGObject/GTK3-based GUI application
- Implements a two-column layout: active customers (left) vs inactive customers (right)
- Features system tray integration using `pystray`
-- Uses a modern dark theme with predefined color scheme
+- Uses GNOME-style theming with CSS styling for cards
- Includes search functionality across customers, locations, and hosts
+- HeaderBar for native GNOME look and feel
**models.py** - Data model definitions using dataclasses
-- `Host`: Individual services/endpoints (SSH, Web, SMB, etc.)
-- `Location`: Customer locations (headquarters, branch offices) with VPN configurations
-- `Customer`: Top-level entities containing multiple locations
+- `Service`: Individual services (Web GUI, SSH, RDP, etc.) on hosts
+- `Host`: Physical/virtual machines with services and sub-hosts (VMs)
+- `Location`: Customer locations with VPN configurations and host infrastructure
+- `CustomerService`: Customer's cloud/web services (O365, CRM, etc.)
+- `Customer`: Top-level entities containing services and locations
- Each model includes helper methods for common operations
**data_loader.py** - Data management layer
-- `load_customers()`: Currently returns mock data, designed to be replaceable
+- `load_customers()`: Returns comprehensive mock data with realistic infrastructure
- `save_customers()`: Placeholder for future persistence
- Isolates data loading logic from UI components
+**widgets/** - Modular UI components using PyGObject
+- `customer_card.py`: `ActiveCustomerCard` and `InactiveCustomerCard` classes
+- `location_card.py`: `ActiveLocationCard` and `InactiveLocationCard` classes
+- `host_item.py`: `HostItem` class for displaying hosts and their services
+- `__init__.py`: Widget exports for clean imports
+
### Key Architecture Patterns
-**Hierarchical Data Structure**: Customer → Location → Host
-- Customers can have multiple locations (e.g., headquarters, branches)
-- Each location has its own VPN configuration and connection state
-- Locations contain multiple hosts/services that become accessible when VPN is connected
+**Hierarchical Data Structure**: Customer → Location → Host → Service
+- Customers have cloud services (accessible anywhere) and multiple locations
+- Each location has VPN configuration, connection state, and host infrastructure
+- Hosts can have sub-hosts (VMs under hypervisors) and multiple services
+- Services represent endpoints (SSH, Web GUI, RDP, etc.) that can be launched
**Active/Inactive Location Management**:
- Locations (not customers) are activated/deactivated individually
-- Left column shows customers with at least one active location (displaying only their active locations)
-- Right column shows customers with at least one inactive location (displaying only their inactive locations)
+- Left column shows customers with active locations (full detail view)
+- Right column shows customers with inactive locations (summary cards)
- UI automatically reorganizes based on location activation state
+**Widget-Based UI Architecture**:
+- Modular widget classes handle their own GTK widget creation
+- Callback system for widget-to-main-window communication
+- Clean separation between data models and UI representation
+
**Mock Implementation**:
- Currently a UI mockup with no actual VPN functionality
- All VPN operations (connect, disconnect, routes) are placeholder methods
- Button actions update UI state but don't perform real network operations
+- Rich mock data includes hypervisors with VMs, various service types
### Data Flow
-1. `data_loader.load_customers()` provides initial customer data
+1. `data_loader.load_customers()` provides initial customer data with full infrastructure
2. Main window loads and filters data based on search terms
-3. UI renders two-column layout based on location active states
-4. User interactions update dataclass attributes directly
+3. Widget classes create GTK components for customers, locations, and hosts
+4. User interactions trigger callbacks that update dataclass attributes
5. UI re-renders to reflect state changes
### UI Layout Structure
-- Header: Search bar and application title
-- Two-column main area with independent scrolling
-- Left column shows full location details with hosts for active locations
-- Right column shows summary cards with activation buttons for inactive locations
+- HeaderBar with title and subtitle (GNOME HIG compliance)
+- Search entry with placeholder text for filtering
+- Two-column main area with independent scrolling containers
+- Left column: Active locations with full infrastructure details
+- Right column: Inactive locations with summary cards and activation buttons
+- GNOME-style cards with CSS theming and proper spacing
- System tray integration for minimize-to-tray behavior
+### GTK3/PyGObject Specific Features
+- CSS styling for GNOME-style cards with borders, shadows, and theming
+- Native GTK widgets: HeaderBar, SearchEntry, ScrolledWindow
+- Proper GNOME HIG compliance for spacing, margins, and layout
+- Button styling with suggested-action and destructive-action classes
+- Thread-safe system tray integration using GLib.idle_add
+
### Future Extensibility
- Replace `load_customers()` with real data source (database, config files, API)
- Implement actual VPN connection logic in placeholder methods
-- Add persistence through `save_customers()` implementation
\ No newline at end of file
+- Add persistence through `save_customers()` implementation
+- Extend widget system for additional UI components
+- Add configuration management for VPN client integration
\ No newline at end of file
diff --git a/control-panel.png b/control-panel.png
new file mode 100644
index 0000000..de80b50
Binary files /dev/null and b/control-panel.png differ
diff --git a/data_loader.py b/data_loader.py
index abc9216..d54f382 100644
--- a/data_loader.py
+++ b/data_loader.py
@@ -1,147 +1,276 @@
+from models import Customer, CustomerService, Location, Host, Service, ServiceType, HostType, VPNType
from typing import List
-from models import Customer, Location, Host
def load_customers() -> List[Customer]:
- """Load customer data. For now, returns mock data but can be replaced later."""
+ """Load customer data. Currently returns mock data for demonstration."""
- # Create hosts for each location
- alpha_hq_hosts = [
- Host(name="Web Server", address="10.10.1.5", type="SSH"),
- Host(name="Admin Panel", address="https://10.10.1.10:8443", type="Web"),
- Host(name="Database", address="10.10.1.20", type="SSH"),
+ customers = []
+
+ # Customer 1: TechCorp Solutions
+ techcorp = Customer(name="TechCorp Solutions")
+
+ # TechCorp's cloud services
+ techcorp.services = [
+ CustomerService("Office 365", "https://portal.office.com", "Email & Office"),
+ CustomerService("Pascom Cloud PBX", "https://techcorp.pascom.cloud", "Phone System"),
+ CustomerService("Salesforce CRM", "https://techcorp.salesforce.com", "CRM")
]
- alpha_branch_hosts = [
- Host(name="File Server", address="10.10.2.5", type="SMB"),
- Host(name="Backup Server", address="10.10.2.10", type="SSH"),
- ]
-
- beta_main_hosts = [
- Host(name="Main Portal", address="https://portal.beta.local", type="Web"),
- Host(name="File Server", address="192.168.50.10", type="SMB"),
- ]
-
- gamma_dc_hosts = [
- Host(name="Application Server", address="172.16.0.100", type="SSH"),
- Host(name="Monitoring Dashboard", address="https://monitor.gamma.net", type="Web"),
- Host(name="Backup Server", address="172.16.0.150", type="SSH"),
- ]
-
- gamma_dr_hosts = [
- Host(name="DR Server", address="172.16.1.100", type="SSH"),
- Host(name="Backup Storage", address="172.16.1.150", type="SMB"),
- ]
-
- delta_dev_hosts = [
- Host(name="Development Server", address="10.20.30.40", type="SSH"),
- Host(name="CI/CD Pipeline", address="https://jenkins.delta.local:8080", type="Web"),
- ]
-
- epsilon_prod_hosts = [
- Host(name="Production API", address="https://api.epsilon.com", type="Web"),
- Host(name="Database Cluster", address="10.5.0.50", type="PostgreSQL"),
- Host(name="Redis Cache", address="10.5.0.60", type="Redis"),
- ]
-
- epsilon_staging_hosts = [
- Host(name="Staging API", address="https://staging-api.epsilon.com", type="Web"),
- Host(name="Test Database", address="10.5.1.50", type="PostgreSQL"),
- ]
-
- # Create locations
- alpha_hq = Location(
- name="Headquarters",
- vpn_type="OpenVPN",
- connected=False,
- active=True,
- hosts=alpha_hq_hosts
- )
-
- alpha_branch = Location(
- name="Branch Office",
- vpn_type="WireGuard",
- connected=False,
- active=False,
- hosts=alpha_branch_hosts
- )
-
- beta_main = Location(
+ # TechCorp's main office location
+ main_office = Location(
name="Main Office",
- vpn_type="WireGuard",
+ vpn_type=VPNType.OPENVPN,
connected=True,
active=True,
- hosts=beta_main_hosts
+ vpn_config="/etc/openvpn/techcorp-main.ovpn"
)
- gamma_dc = Location(
- name="Data Center",
- vpn_type="IPSec",
- connected=False,
- active=False,
- hosts=gamma_dc_hosts
+ # Proxmox hypervisor with VMs
+ proxmox_host = Host(
+ name="PVE-01",
+ ip_address="192.168.1.10",
+ host_type=HostType.PROXMOX,
+ description="Main virtualization server",
+ services=[
+ Service("Web Interface", ServiceType.WEB_GUI, 8006),
+ Service("SSH", ServiceType.SSH, 22)
+ ]
)
- gamma_dr = Location(
- name="DR Site",
- vpn_type="OpenVPN",
- connected=False,
- active=False,
- hosts=gamma_dr_hosts
- )
-
- delta_dev = Location(
- name="Development Lab",
- vpn_type="OpenVPN",
- connected=False,
- active=False,
- hosts=delta_dev_hosts
- )
-
- epsilon_prod = Location(
- name="Production",
- vpn_type="WireGuard",
- connected=False,
- active=False,
- hosts=epsilon_prod_hosts
- )
-
- epsilon_staging = Location(
- name="Staging",
- vpn_type="OpenVPN",
- connected=False,
- active=False,
- hosts=epsilon_staging_hosts
- )
-
- # Create customers
- customers = [
- Customer(
- name="Customer Alpha Corp",
- locations=[alpha_hq, alpha_branch]
+ # VMs running on Proxmox
+ proxmox_host.sub_hosts = [
+ Host(
+ name="DC-01",
+ ip_address="192.168.1.20",
+ host_type=HostType.WINDOWS_SERVER,
+ description="Domain Controller",
+ services=[
+ Service("RDP", ServiceType.RDP, 3389),
+ Service("Admin Web", ServiceType.WEB_GUI, 8080)
+ ]
),
- Customer(
- name="Beta Industries",
- locations=[beta_main]
- ),
- Customer(
- name="Gamma Solutions",
- locations=[gamma_dc, gamma_dr]
- ),
- Customer(
- name="Delta Tech",
- locations=[delta_dev]
- ),
- Customer(
- name="Epsilon Systems",
- locations=[epsilon_prod, epsilon_staging]
+ Host(
+ name="FILE-01",
+ ip_address="192.168.1.21",
+ host_type=HostType.LINUX,
+ description="File Server (Samba)",
+ services=[
+ Service("SSH", ServiceType.SSH, 22),
+ Service("SMB Share", ServiceType.SMB, 445),
+ Service("Web Panel", ServiceType.WEB_GUI, 9000)
+ ]
),
+ Host(
+ name="DB-01",
+ ip_address="192.168.1.22",
+ host_type=HostType.LINUX,
+ description="PostgreSQL Database",
+ services=[
+ Service("SSH", ServiceType.SSH, 22),
+ Service("PostgreSQL", ServiceType.DATABASE, 5432),
+ Service("pgAdmin", ServiceType.WEB_GUI, 5050)
+ ]
+ )
]
+ # Network infrastructure
+ router = Host(
+ name="FW-01",
+ ip_address="192.168.1.1",
+ host_type=HostType.ROUTER,
+ description="pfSense Firewall/Router",
+ services=[
+ Service("Web Interface", ServiceType.WEB_GUI, 443),
+ Service("SSH", ServiceType.SSH, 22)
+ ]
+ )
+
+ switch = Host(
+ name="SW-01",
+ ip_address="192.168.1.2",
+ host_type=HostType.SWITCH,
+ description="Managed Switch",
+ services=[
+ Service("Web Interface", ServiceType.WEB_GUI, 80),
+ Service("SSH", ServiceType.SSH, 22)
+ ]
+ )
+
+ main_office.hosts = [proxmox_host, router, switch]
+
+ # Branch office location
+ branch_office = Location(
+ name="Branch Office",
+ vpn_type=VPNType.WIREGUARD,
+ connected=False,
+ active=False,
+ vpn_config="/etc/wireguard/techcorp-branch.conf"
+ )
+
+ branch_server = Host(
+ name="BRANCH-01",
+ ip_address="10.10.1.10",
+ host_type=HostType.LINUX,
+ description="Branch office server",
+ services=[
+ Service("SSH", ServiceType.SSH, 22),
+ Service("File Share", ServiceType.SMB, 445),
+ Service("Local Web", ServiceType.WEB_GUI, 8080)
+ ]
+ )
+
+ branch_office.hosts = [branch_server]
+
+ techcorp.locations = [main_office, branch_office]
+ customers.append(techcorp)
+
+ # Customer 2: MedPractice Group
+ medpractice = Customer(name="MedPractice Group")
+
+ # MedPractice's cloud services
+ medpractice.services = [
+ CustomerService("Google Workspace", "https://workspace.google.com", "Email & Office"),
+ CustomerService("Practice Management", "https://medpractice.emr-system.com", "EMR System"),
+ CustomerService("VoIP Provider", "https://medpractice.voip.com", "Phone System")
+ ]
+
+ # Clinic location
+ clinic_location = Location(
+ name="Main Clinic",
+ vpn_type=VPNType.WIREGUARD,
+ connected=False,
+ active=False,
+ vpn_config="/etc/wireguard/medpractice.conf"
+ )
+
+ # ESXi hypervisor
+ esxi_host = Host(
+ name="ESXi-01",
+ ip_address="10.0.1.10",
+ host_type=HostType.ESXI,
+ description="VMware ESXi Host",
+ services=[
+ Service("vSphere Web", ServiceType.WEB_GUI, 443),
+ Service("SSH", ServiceType.SSH, 22)
+ ]
+ )
+
+ # VMs on ESXi
+ esxi_host.sub_hosts = [
+ Host(
+ name="WIN-SRV-01",
+ ip_address="10.0.1.20",
+ host_type=HostType.WINDOWS_SERVER,
+ description="Windows Server 2022",
+ services=[
+ Service("RDP", ServiceType.RDP, 3389),
+ Service("IIS Web", ServiceType.WEB_GUI, 80)
+ ]
+ ),
+ Host(
+ name="BACKUP-01",
+ ip_address="10.0.1.21",
+ host_type=HostType.LINUX,
+ description="Backup Server",
+ services=[
+ Service("SSH", ServiceType.SSH, 22),
+ Service("Backup Web UI", ServiceType.WEB_GUI, 8080)
+ ]
+ )
+ ]
+
+ # Physical server
+ physical_server = Host(
+ name="PHYS-01",
+ ip_address="10.0.1.50",
+ host_type=HostType.LINUX,
+ description="Physical Linux Server",
+ services=[
+ Service("SSH", ServiceType.SSH, 22),
+ Service("Docker Portainer", ServiceType.WEB_GUI, 9000),
+ Service("Nginx Proxy", ServiceType.WEB_GUI, 8080)
+ ]
+ )
+
+ clinic_location.hosts = [esxi_host, physical_server]
+ medpractice.locations = [clinic_location]
+ customers.append(medpractice)
+
+ # Customer 3: Manufacturing Inc
+ manufacturing = Customer(name="Manufacturing Inc")
+
+ # Manufacturing's cloud services
+ manufacturing.services = [
+ CustomerService("Microsoft 365", "https://portal.office.com", "Email & Office"),
+ CustomerService("SAP Cloud", "https://manufacturing.sap.com", "ERP System")
+ ]
+
+ # Factory location
+ factory_location = Location(
+ name="Factory Floor",
+ vpn_type=VPNType.IPSEC,
+ connected=False,
+ active=True,
+ vpn_config="/etc/ipsec.d/manufacturing.conf"
+ )
+
+ # Manufacturing infrastructure - simpler setup
+ linux_server = Host(
+ name="PROD-01",
+ ip_address="172.16.1.10",
+ host_type=HostType.LINUX,
+ description="Production Server",
+ services=[
+ Service("SSH", ServiceType.SSH, 22),
+ Service("Web Portal", ServiceType.WEB_GUI, 8443),
+ Service("FTP", ServiceType.FTP, 21)
+ ]
+ )
+
+ nas_server = Host(
+ name="NAS-01",
+ ip_address="172.16.1.20",
+ host_type=HostType.LINUX,
+ description="Network Attached Storage",
+ services=[
+ Service("SSH", ServiceType.SSH, 22),
+ Service("Web Interface", ServiceType.WEB_GUI, 5000),
+ Service("SMB Share", ServiceType.SMB, 445)
+ ]
+ )
+
+ factory_location.hosts = [linux_server, nas_server]
+
+ # Office location
+ office_location = Location(
+ name="Administrative Office",
+ vpn_type=VPNType.OPENVPN,
+ connected=False,
+ active=False,
+ vpn_config="/etc/openvpn/manufacturing-office.ovpn"
+ )
+
+ office_server = Host(
+ name="OFFICE-01",
+ ip_address="172.16.2.10",
+ host_type=HostType.WINDOWS_SERVER,
+ description="Office domain controller",
+ services=[
+ Service("RDP", ServiceType.RDP, 3389),
+ Service("File Share", ServiceType.SMB, 445)
+ ]
+ )
+
+ office_location.hosts = [office_server]
+
+ manufacturing.locations = [factory_location, office_location]
+ customers.append(manufacturing)
+
return customers
def save_customers(customers: List[Customer]) -> None:
- """Save customer data. Placeholder for future implementation."""
- # TODO: Implement saving to file/database
+ """Save customer data. Currently a placeholder."""
+ # TODO: Implement actual persistence (JSON file, database, etc.)
pass
\ No newline at end of file
diff --git a/main.py b/main.py
index 93a4d9f..b8704a1 100644
--- a/main.py
+++ b/main.py
@@ -1,12 +1,14 @@
#!/usr/bin/env python3
import gi
gi.require_version('Gtk', '3.0')
-gi.require_version('AppIndicator3', '0.1')
-from gi.repository import Gtk, Gdk, GLib, Gio, AppIndicator3
-import threading
+from gi.repository import Gtk, Gdk, GLib
import sys
-from models import Customer, Location, Host
+import threading
+import pystray
+from PIL import Image, ImageDraw
+from models import Customer
from data_loader import load_customers
+from widgets import ActiveCustomerCard, InactiveCustomerCard
class VPNManagerWindow:
@@ -18,9 +20,10 @@ class VPNManagerWindow:
self.window = Gtk.Window()
self.window.set_title("VPN Manager")
self.window.set_default_size(1200, 750)
- self.window.connect("delete-event", self.hide_window)
+ self.window.connect("delete-event", self.quit_app_from_close)
+ self.window.connect("window-state-event", self.on_window_state_event)
- # Set up CSS for dark theme
+# Set up minimal CSS for GNOME-style cards
self.setup_css()
# Create UI
@@ -29,184 +32,18 @@ class VPNManagerWindow:
# Start hidden
self.window.hide()
-
+
def setup_css(self):
+ """Minimal CSS for GNOME-style cards"""
css_provider = Gtk.CssProvider()
css = """
- window {
- background-color: #1a1d29;
- color: #e8eaf6;
- }
-
- .header {
- background: linear-gradient(135deg, #1a1d29 0%, #252836 100%);
- padding: 20px;
- }
-
- .title {
- font-size: 20px;
- font-weight: bold;
- color: #e8eaf6;
- }
-
- .search-entry {
- background-color: #2d3142;
- color: #e8eaf6;
- border: 1px solid #5e72e4;
+ .card {
+ background: @theme_base_color;
border-radius: 8px;
- padding: 10px;
- margin: 10px 0;
- }
-
- .customer-card {
- background-color: #252836;
- border: 1px solid #3a3f5c;
- border-radius: 8px;
- margin: 8px 5px;
- padding: 15px;
- }
-
- .location-card {
- background-color: #2a2e3f;
- border: 1px solid #3a3f5c;
- border-radius: 6px;
- margin: 5px 20px 5px 20px;
- padding: 10px;
- }
-
- .host-item {
- background-color: #1a1d29;
- border: 1px solid #3a3f5c;
- border-radius: 4px;
- margin: 3px 2px;
- padding: 6px 10px;
- }
-
- .active-title {
- color: #2dce89;
- font-weight: bold;
- font-size: 14px;
- }
-
- .inactive-title {
- color: #8892b0;
- font-weight: bold;
- font-size: 14px;
- }
-
- .customer-name {
- color: #5e72e4;
- font-weight: bold;
- font-size: 14px;
- }
-
- .inactive-customer-name {
- color: #8892b0;
- font-weight: bold;
- font-size: 14px;
- }
-
- .location-name {
- color: #e8eaf6;
- font-weight: bold;
- font-size: 12px;
- }
-
- .vpn-type {
- color: #8892b0;
- font-size: 10px;
- }
-
- .connected-status {
- color: #2dce89;
- font-weight: bold;
- font-size: 10px;
- }
-
- .disconnected-status {
- color: #f5365c;
- font-weight: bold;
- font-size: 10px;
- }
-
- .connect-button {
- background-color: #5e72e4;
- color: white;
- border: none;
- border-radius: 4px;
- padding: 5px 15px;
- font-weight: bold;
- }
-
- .connect-button:hover {
- background-color: #3a3f5c;
- }
-
- .disconnect-button {
- background-color: #f5365c;
- color: white;
- border: none;
- border-radius: 4px;
- padding: 5px 15px;
- font-weight: bold;
- }
-
- .disconnect-button:hover {
- background-color: #3a3f5c;
- }
-
- .routes-button, .deactivate-button, .activate-button {
- background-color: #3a3f5c;
- color: #e8eaf6;
- border: none;
- border-radius: 4px;
- padding: 5px 15px;
- margin: 0 3px;
- }
-
- .deactivate-button {
- background-color: #fb6340;
- color: white;
- }
-
- .activate-button {
- background-color: #5e72e4;
- color: white;
- font-weight: bold;
- padding: 8px 20px;
- }
-
- .launch-button {
- background-color: #5e72e4;
- color: white;
- border: none;
- border-radius: 4px;
- padding: 4px 12px;
- font-weight: bold;
- font-size: 9px;
- }
-
- .host-name {
- color: #e8eaf6;
- font-weight: bold;
- font-size: 9px;
- }
-
- .host-address {
- color: #8892b0;
- font-size: 8px;
- font-family: monospace;
- }
-
- .services-header {
- color: #5e72e4;
- font-weight: bold;
- font-size: 10px;
- }
-
- .service-count {
- color: #6c757d;
- font-size: 9px;
+ 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())
@@ -217,115 +54,125 @@ class VPNManagerWindow:
style_context.add_provider_for_screen(
screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
)
+
def setup_ui(self):
- # Main container
- main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ # Use HeaderBar for native GNOME look
+ header_bar = Gtk.HeaderBar()
+ header_bar.set_show_close_button(True)
+ header_bar.set_title("VPN Manager")
+ header_bar.set_subtitle("Connection Manager")
+ self.window.set_titlebar(header_bar)
+
+ # Main container with proper spacing
+ main_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
+ main_vbox.set_margin_start(12)
+ main_vbox.set_margin_end(12)
+ main_vbox.set_margin_top(12)
+ main_vbox.set_margin_bottom(12)
self.window.add(main_vbox)
- # Header
- header_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
- header_box.get_style_context().add_class("header")
- main_vbox.pack_start(header_box, False, False, 0)
-
- # Title with icon
- title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
- title_box.set_halign(Gtk.Align.CENTER)
- header_box.pack_start(title_box, False, False, 10)
-
- icon_label = Gtk.Label(label="🛡️")
- icon_label.set_markup('🛡️')
- title_box.pack_start(icon_label, False, False, 0)
-
- title_label = Gtk.Label(label="VPN Connection Manager")
- title_label.get_style_context().add_class("title")
- title_box.pack_start(title_label, False, False, 0)
-
- # Search bar
- search_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=0)
- search_box.set_margin_start(20)
- search_box.set_margin_end(20)
- header_box.pack_start(search_box, False, False, 0)
-
- search_icon = Gtk.Label(label="🔍")
- search_box.pack_start(search_icon, False, False, 10)
-
- self.search_entry = Gtk.Entry()
+ # Search bar with SearchEntry
+ self.search_entry = Gtk.SearchEntry()
self.search_entry.set_placeholder_text("Search customers, locations, or hosts...")
- self.search_entry.get_style_context().add_class("search-entry")
- self.search_entry.connect("changed", self.filter_customers)
- search_box.pack_start(self.search_entry, True, True, 0)
+ self.search_entry.connect("search-changed", self.filter_customers)
+ main_vbox.pack_start(self.search_entry, False, False, 0)
- # Main content area with two columns
- columns_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10, homogeneous=True)
- columns_box.set_margin_start(10)
- columns_box.set_margin_end(10)
- columns_box.set_margin_bottom(10)
+ # Clean two-column layout like GNOME Control Center
+ columns_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=24)
main_vbox.pack_start(columns_box, True, True, 0)
# Left column - Active customers
- left_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
+ left_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
columns_box.pack_start(left_vbox, True, True, 0)
- active_header = Gtk.Label(label="✅ Active Customers")
- active_header.get_style_context().add_class("active-title")
- active_header.set_halign(Gtk.Align.START)
- left_vbox.pack_start(active_header, False, False, 0)
+ # Simple label header
+ active_label = Gtk.Label()
+ active_label.set_markup("Active Customers")
+ active_label.set_halign(Gtk.Align.START)
+ left_vbox.pack_start(active_label, False, False, 0)
- # Active customers scrolled window
+ # Clean scrolled window without borders
active_scrolled = Gtk.ScrolledWindow()
active_scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+ active_scrolled.set_shadow_type(Gtk.ShadowType.NONE)
left_vbox.pack_start(active_scrolled, True, True, 0)
- self.active_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ self.active_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
active_scrolled.add(self.active_box)
- # Right column - Inactive customers
- right_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10)
+ # Right column - Inactive customers
+ right_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
columns_box.pack_start(right_vbox, True, True, 0)
- inactive_header = Gtk.Label(label="💤 Inactive Customers")
- inactive_header.get_style_context().add_class("inactive-title")
- inactive_header.set_halign(Gtk.Align.START)
- right_vbox.pack_start(inactive_header, False, False, 0)
+ # Simple label header
+ inactive_label = Gtk.Label()
+ inactive_label.set_markup("Inactive Customers")
+ inactive_label.set_halign(Gtk.Align.START)
+ right_vbox.pack_start(inactive_label, False, False, 0)
- # Inactive customers scrolled window
+ # Clean scrolled window without borders
inactive_scrolled = Gtk.ScrolledWindow()
inactive_scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
+ inactive_scrolled.set_shadow_type(Gtk.ShadowType.NONE)
right_vbox.pack_start(inactive_scrolled, True, True, 0)
- self.inactive_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
+ self.inactive_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
inactive_scrolled.add(self.inactive_box)
# Render initial data
self.render_customers()
def setup_system_tray(self):
- self.indicator = AppIndicator3.Indicator.new(
- "vpn-manager",
- "network-vpn",
- AppIndicator3.IndicatorCategory.APPLICATION_STATUS
+ # 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)
+
+ # 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)
+
+ return image
+
+ # 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"
)
- self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
- # Create menu
- menu = Gtk.Menu()
+ # Set direct click action
+ self.tray_icon.default_action = self.show_window_from_tray
- # Open item
- open_item = Gtk.MenuItem(label="Open VPN Manager")
- open_item.connect("activate", self.show_window_from_tray)
- menu.append(open_item)
+ # 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
- # Separator
- menu.append(Gtk.SeparatorMenuItem())
-
- # Quit item
- quit_item = Gtk.MenuItem(label="Quit")
- quit_item.connect("activate", self.quit_app)
- menu.append(quit_item)
-
- menu.show_all()
- self.indicator.set_menu(menu)
+ # Start tray icon in separate thread
+ threading.Thread(target=self.tray_icon.run, daemon=True).start()
+
+ def get_callbacks(self):
+ """Return callback functions for widget interactions"""
+ return {
+ 'toggle_connection': self.toggle_connection,
+ 'set_location_active': self.set_location_active,
+ 'deactivate_location': self.deactivate_location,
+ 'open_service': self.open_service,
+ 'open_customer_service': self.open_customer_service
+ }
def render_customers(self):
# Clear existing content
@@ -343,237 +190,34 @@ class VPNManagerWindow:
inactive_locations = customer.get_inactive_locations()
if active_locations:
- customer_data = Customer(
- name=customer.name,
- locations=active_locations
- )
+ from models import Customer
+ customer_data = Customer(name=customer.name)
+ customer_data.services = customer.services
+ customer_data.locations = active_locations
customers_with_active.append(customer_data)
if inactive_locations:
- customer_data = Customer(
- name=customer.name,
- locations=inactive_locations
- )
+ from models import Customer
+ customer_data = Customer(name=customer.name)
+ customer_data.services = customer.services
+ customer_data.locations = inactive_locations
customers_with_inactive.append(customer_data)
- # Render active customers
- for customer in customers_with_active:
- self.create_customer_with_active_locations(customer)
+ # Get callbacks for widgets
+ callbacks = self.get_callbacks()
- # Render inactive customers
+ # Render active customers using widget classes
+ for customer in customers_with_active:
+ customer_card = ActiveCustomerCard(customer, callbacks)
+ self.active_box.pack_start(customer_card.widget, False, False, 0)
+
+ # Render inactive customers using widget classes
for customer in customers_with_inactive:
- self.create_customer_without_active_locations(customer)
+ customer_card = InactiveCustomerCard(customer, callbacks)
+ self.inactive_box.pack_start(customer_card.widget, False, False, 0)
self.window.show_all()
- def create_customer_with_active_locations(self, customer):
- # Customer card
- customer_frame = Gtk.Frame()
- customer_frame.get_style_context().add_class("customer-card")
- self.active_box.pack_start(customer_frame, False, False, 0)
-
- customer_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
- customer_frame.add(customer_vbox)
-
- # Customer header
- customer_label = Gtk.Label(label=f"🏢 {customer.name}")
- customer_label.get_style_context().add_class("customer-name")
- customer_label.set_halign(Gtk.Align.START)
- customer_vbox.pack_start(customer_label, False, False, 0)
-
- # Render each location
- for location in customer.locations:
- self.create_active_location_card(location, customer_vbox, customer.name)
-
- def create_active_location_card(self, location, parent_box, customer_name):
- # Location card
- location_frame = Gtk.Frame()
- location_frame.get_style_context().add_class("location-card")
- parent_box.pack_start(location_frame, False, False, 0)
-
- location_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
- location_frame.add(location_vbox)
-
- # Location header with controls
- header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
- location_vbox.pack_start(header_box, False, False, 0)
-
- # Location info
- info_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
- header_box.pack_start(info_vbox, True, True, 0)
-
- location_label = Gtk.Label(label=f"📍 {location.name}")
- location_label.get_style_context().add_class("location-name")
- location_label.set_halign(Gtk.Align.START)
- info_vbox.pack_start(location_label, False, False, 0)
-
- # VPN type
- vpn_icons = {
- "OpenVPN": "🔒",
- "WireGuard": "⚡",
- "IPSec": "🛡️"
- }
- vpn_icon = vpn_icons.get(location.vpn_type, "🔑")
-
- type_label = Gtk.Label(label=f"{vpn_icon} {location.vpn_type} VPN")
- type_label.get_style_context().add_class("vpn-type")
- type_label.set_halign(Gtk.Align.START)
- info_vbox.pack_start(type_label, False, False, 0)
-
- # Controls
- controls_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5)
- header_box.pack_end(controls_box, False, False, 0)
-
- # Status
- status_text = "● Connected" if location.connected else "○ Disconnected"
- status_label = Gtk.Label(label=status_text)
- if location.connected:
- status_label.get_style_context().add_class("connected-status")
- else:
- status_label.get_style_context().add_class("disconnected-status")
- controls_box.pack_start(status_label, False, False, 0)
-
- # Connect/Disconnect button
- btn_text = "Disconnect" if location.connected else "Connect"
- connect_btn = Gtk.Button(label=btn_text)
- if location.connected:
- connect_btn.get_style_context().add_class("disconnect-button")
- else:
- connect_btn.get_style_context().add_class("connect-button")
- connect_btn.connect("clicked", lambda btn, l=location: self.toggle_connection(l))
- controls_box.pack_start(connect_btn, False, False, 0)
-
- # Routes button
- routes_btn = Gtk.Button(label="Routes")
- routes_btn.get_style_context().add_class("routes-button")
- routes_btn.connect("clicked", lambda btn, l=location: self.set_route(l))
- controls_box.pack_start(routes_btn, False, False, 0)
-
- # Deactivate button
- deactivate_btn = Gtk.Button(label="Deactivate")
- deactivate_btn.get_style_context().add_class("deactivate-button")
- deactivate_btn.connect("clicked", lambda btn, l=location: self.deactivate_location(l, customer_name))
- controls_box.pack_start(deactivate_btn, False, False, 0)
-
- # Hosts section
- if location.hosts:
- # Separator
- separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
- location_vbox.pack_start(separator, False, False, 5)
-
- services_label = Gtk.Label(label="💼 Available Services")
- services_label.get_style_context().add_class("services-header")
- services_label.set_halign(Gtk.Align.START)
- location_vbox.pack_start(services_label, False, False, 0)
-
- for host in location.hosts:
- self.create_host_item(host, location_vbox)
-
- def create_host_item(self, host, parent_box):
- host_frame = Gtk.Frame()
- host_frame.get_style_context().add_class("host-item")
- parent_box.pack_start(host_frame, False, False, 0)
-
- host_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
- host_frame.add(host_box)
-
- # Icon
- type_icons = {
- "SSH": "💻",
- "Web": "🌐",
- "SMB": "📂",
- "PostgreSQL": "🗃️",
- "Redis": "🗂️"
- }
- icon = type_icons.get(host.type, "📡")
-
- icon_label = Gtk.Label(label=icon)
- host_box.pack_start(icon_label, False, False, 0)
-
- # Host details
- details_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
- host_box.pack_start(details_vbox, True, True, 0)
-
- name_label = Gtk.Label(label=host.name)
- name_label.get_style_context().add_class("host-name")
- name_label.set_halign(Gtk.Align.START)
- details_vbox.pack_start(name_label, False, False, 0)
-
- addr_label = Gtk.Label(label=host.address)
- addr_label.get_style_context().add_class("host-address")
- addr_label.set_halign(Gtk.Align.START)
- details_vbox.pack_start(addr_label, False, False, 0)
-
- # Launch button for SSH and Web services
- if host.type in ["SSH", "Web"]:
- launch_btn = Gtk.Button(label="Launch")
- launch_btn.get_style_context().add_class("launch-button")
- launch_btn.connect("clicked", lambda btn, h=host: self.open_service(h))
- host_box.pack_end(launch_btn, False, False, 0)
-
- def create_customer_without_active_locations(self, customer):
- # Customer card
- customer_frame = Gtk.Frame()
- customer_frame.get_style_context().add_class("customer-card")
- self.inactive_box.pack_start(customer_frame, False, False, 0)
-
- customer_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5)
- customer_frame.add(customer_vbox)
-
- # Customer header
- customer_label = Gtk.Label(label=f"🏢 {customer.name}")
- customer_label.get_style_context().add_class("inactive-customer-name")
- customer_label.set_halign(Gtk.Align.START)
- customer_vbox.pack_start(customer_label, False, False, 0)
-
- # Render each location
- for location in customer.locations:
- self.create_inactive_location_card(location, customer_vbox, customer.name)
-
- def create_inactive_location_card(self, location, parent_box, customer_name):
- # Location card
- location_frame = Gtk.Frame()
- location_frame.get_style_context().add_class("location-card")
- parent_box.pack_start(location_frame, False, False, 0)
-
- location_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
- location_frame.add(location_hbox)
-
- # Location info
- info_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
- location_hbox.pack_start(info_vbox, True, True, 0)
-
- location_label = Gtk.Label(label=f"📍 {location.name}")
- location_label.get_style_context().add_class("location-name")
- location_label.set_halign(Gtk.Align.START)
- info_vbox.pack_start(location_label, False, False, 0)
-
- # VPN type
- vpn_icons = {
- "OpenVPN": "🔒",
- "WireGuard": "⚡",
- "IPSec": "🛡️"
- }
- vpn_icon = vpn_icons.get(location.vpn_type, "🔑")
-
- type_label = Gtk.Label(label=f"{vpn_icon} {location.vpn_type} VPN")
- type_label.get_style_context().add_class("vpn-type")
- type_label.set_halign(Gtk.Align.START)
- info_vbox.pack_start(type_label, False, False, 0)
-
- # Service count
- service_count = len(location.hosts)
- count_label = Gtk.Label(label=f"📊 {service_count} services available")
- count_label.get_style_context().add_class("service-count")
- count_label.set_halign(Gtk.Align.START)
- info_vbox.pack_start(count_label, False, False, 0)
-
- # Activate button
- activate_btn = Gtk.Button(label="Set Active")
- activate_btn.get_style_context().add_class("activate-button")
- activate_btn.connect("clicked", lambda btn, l=location: self.set_location_active(l, customer_name))
- location_hbox.pack_end(activate_btn, False, False, 0)
-
def set_location_active(self, location, customer_name):
for customer in self.customers:
if customer.name == customer_name:
@@ -590,6 +234,7 @@ class VPNManagerWindow:
target_location = customer.get_location_by_name(location.name)
if target_location:
target_location.active = False
+ target_location.connected = False # Disconnect when deactivating
print(f"Mock: Deactivating {customer.name} - {target_location.name}")
break
self.render_customers()
@@ -599,24 +244,50 @@ class VPNManagerWindow:
if search_term:
self.filtered_customers = []
for customer in self.customers:
+ # Check if search term matches customer name
if search_term in customer.name.lower():
self.filtered_customers.append(customer)
- else:
- matching_locations = []
- for location in customer.locations:
- if (search_term in location.name.lower() or
- search_term in location.vpn_type.lower() or
- any(search_term in h.name.lower() or
- search_term in h.address.lower()
- for h in location.hosts)):
- matching_locations.append(location)
+ continue
+
+ # Check customer services
+ if any(search_term in service.name.lower() or
+ search_term in service.url.lower() or
+ search_term in service.service_type.lower()
+ for service in customer.services):
+ self.filtered_customers.append(customer)
+ continue
+
+ # Check locations and their hosts
+ for location in customer.locations:
+ # Check location name
+ if search_term in location.name.lower():
+ self.filtered_customers.append(customer)
+ break
- if matching_locations:
- filtered_customer = Customer(
- name=customer.name,
- locations=matching_locations
- )
- self.filtered_customers.append(filtered_customer)
+ # Check hosts and their services in this location
+ def search_hosts(hosts):
+ for host in hosts:
+ if (search_term in host.name.lower() or
+ search_term in host.ip_address.lower() or
+ search_term in host.host_type.value.lower() or
+ search_term in host.description.lower()):
+ return True
+
+ # Check host services
+ if any(search_term in service.name.lower() or
+ search_term in str(service.port).lower() or
+ search_term in service.service_type.value.lower()
+ for service in host.services):
+ return True
+
+ # Check sub-hosts recursively
+ if search_hosts(host.sub_hosts):
+ return True
+ return False
+
+ if search_hosts(location.hosts):
+ self.filtered_customers.append(customer)
+ break
else:
self.filtered_customers = self.customers.copy()
@@ -625,24 +296,42 @@ class VPNManagerWindow:
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}")
+ print(f"Mock: {status} {location.name} via {location.vpn_type.value}")
self.render_customers()
- def set_route(self, location):
- print(f"Mock: Setting route for {location.name}")
+ def open_service(self, service):
+ # Get the host IP from context - this would need to be passed properly in a real implementation
+ print(f"Mock: Opening {service.service_type.value} service: {service.name} on port {service.port}")
- def open_service(self, host):
- print(f"Mock: Opening {host.type} service: {host.name} at {host.address}")
+ def open_customer_service(self, customer_service):
+ print(f"Mock: Opening customer service: {customer_service.name} at {customer_service.url}")
- def show_window_from_tray(self, widget=None):
+ def show_window_from_tray(self, _icon=None, _item=None):
+ # Use GLib.idle_add to safely call GTK functions from the tray thread
+ GLib.idle_add(self._show_window_safe)
+
+ def _show_window_safe(self):
+ """Safely show window in main GTK thread"""
+ self.window.deiconify()
self.window.present()
self.window.show_all()
+ return False # Don't repeat the idle call
- def hide_window(self, widget, event):
- self.window.hide()
- return True
+ def on_window_state_event(self, _widget, event):
+ """Handle window state changes - hide to tray when minimized"""
+ if event.new_window_state & Gdk.WindowState.ICONIFIED:
+ self.window.hide()
+ return False
- def quit_app(self, widget=None):
+ def quit_app_from_close(self, _widget=None, _event=None):
+ """Quit app when close button is pressed"""
+ self.quit_app()
+ return False
+
+ def quit_app(self, _widget=None):
+ # Stop the tray icon
+ if hasattr(self, 'tray_icon'):
+ self.tray_icon.stop()
Gtk.main_quit()
sys.exit(0)
diff --git a/models.py b/models.py
index 36bb60e..7b847ea 100644
--- a/models.py
+++ b/models.py
@@ -1,54 +1,154 @@
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from typing import List, Optional
+from enum import Enum
+
+
+class ServiceType(Enum):
+ """Enum for different types of services that can run on hosts."""
+ SSH = "SSH"
+ WEB_GUI = "Web GUI"
+ RDP = "RDP"
+ VNC = "VNC"
+ SMB = "SMB"
+ DATABASE = "Database"
+ FTP = "FTP"
+
+
+@dataclass
+class Service:
+ """Represents a service on a host."""
+ name: str
+ service_type: ServiceType
+ port: int
+
+
+class HostType(Enum):
+ """Enum for different types of hosts."""
+ LINUX = "Linux"
+ WINDOWS = "Windows"
+ WINDOWS_SERVER = "Windows Server"
+ PROXMOX = "Proxmox"
+ ESXI = "ESXi"
+ ROUTER = "Router"
+ SWITCH = "Switch"
+
+
+class VPNType(Enum):
+ """Enum for different VPN types."""
+ OPENVPN = "OpenVPN"
+ WIREGUARD = "WireGuard"
+ IPSEC = "IPSec"
@dataclass
class Host:
- """Represents a host/service within a location."""
+ """Represents a physical or virtual host at a location."""
name: str
- address: str
- type: str # e.g., "SSH", "Web", "SMB", "PostgreSQL", "Redis"
+ ip_address: str
+ host_type: HostType
+ description: str = ""
+ services: List[Service] = field(default_factory=list)
+ sub_hosts: List['Host'] = field(
+ default_factory=list) # For VMs under hypervisors
+
+ def get_service_by_name(self, service_name: str) -> Optional[Service]:
+ """Get a service by its name."""
+ for service in self.services:
+ if service.name == service_name:
+ return service
+ return None
+
+ def is_hypervisor(self) -> bool:
+ """Check if this host has sub-hosts (VMs)."""
+ return len(self.sub_hosts) > 0
@dataclass
class Location:
- """Represents a location within a customer (e.g., headquarters, branch office)."""
+ """Represents a customer location."""
name: str
- vpn_type: str # e.g., "OpenVPN", "WireGuard", "IPSec"
+ vpn_type: VPNType
connected: bool = False
active: bool = False
- hosts: List[Host] = None
-
- def __post_init__(self):
- if self.hosts is None:
- self.hosts = []
+ vpn_config: str = "" # Path to VPN config or connection details
+ hosts: List[Host] = field(default_factory=list)
+
+ def get_host_by_name(self, host_name: str) -> Optional[Host]:
+ """Get a host by its name (searches recursively in sub-hosts)."""
+ def search_hosts(hosts_list: List[Host]) -> Optional[Host]:
+ for host in hosts_list:
+ if host.name == host_name:
+ return host
+ # Search in sub-hosts
+ sub_result = search_hosts(host.sub_hosts)
+ if sub_result:
+ return sub_result
+ return None
+
+ return search_hosts(self.hosts)
+
+ def get_all_hosts_flat(self) -> List[Host]:
+ """Get all hosts including sub-hosts in a flat list."""
+ def collect_hosts(hosts_list: List[Host]) -> List[Host]:
+ result = []
+ for host in hosts_list:
+ result.append(host)
+ result.extend(collect_hosts(host.sub_hosts))
+ return result
+
+ return collect_hosts(self.hosts)
+
+ def get_hypervisors(self) -> List[Host]:
+ """Get all hosts that have sub-hosts (hypervisors)."""
+ return [host for host in self.get_all_hosts_flat() if host.is_hypervisor()]
+
+
+@dataclass
+class CustomerService:
+ """Represents a customer's cloud/web service."""
+ name: str
+ url: str
+ service_type: str # e.g., "Email", "Phone System", "CRM", "ERP"
+ description: str = ""
@dataclass
class Customer:
- """Represents a customer with multiple locations."""
+ """Represents a customer with their services and locations."""
name: str
- locations: List[Location] = None
-
- def __post_init__(self):
- if self.locations is None:
- self.locations = []
-
- def get_active_locations(self) -> List[Location]:
- """Get all active locations for this customer."""
- return [loc for loc in self.locations if loc.active]
-
- def get_inactive_locations(self) -> List[Location]:
- """Get all inactive locations for this customer."""
- return [loc for loc in self.locations if not loc.active]
-
- def has_active_locations(self) -> bool:
- """Check if customer has any active locations."""
- return any(loc.active for loc in self.locations)
-
+
+ # Customer's cloud/web services (available regardless of location)
+ services: List[CustomerService] = field(default_factory=list)
+
+ # Customer's locations with their infrastructure
+ locations: List[Location] = field(default_factory=list)
+
def get_location_by_name(self, location_name: str) -> Optional[Location]:
"""Get a location by its name."""
for location in self.locations:
if location.name == location_name:
return location
- return None
\ No newline at end of file
+ return None
+
+ def get_active_locations(self) -> List[Location]:
+ """Get all active locations for this customer."""
+ return [loc for loc in self.locations if loc.active]
+
+ def get_inactive_locations(self) -> List[Location]:
+ """Get all inactive locations for this customer."""
+ return [loc for loc in self.locations if not loc.active]
+
+ def has_active_locations(self) -> bool:
+ """Check if customer has any active locations."""
+ return any(loc.active for loc in self.locations)
+
+ def has_connected_locations(self) -> bool:
+ """Check if customer has any connected locations."""
+ return any(loc.connected for loc in self.locations)
+
+ def get_all_hosts_flat(self) -> List[Host]:
+ """Get all hosts from all locations in a flat list."""
+ all_hosts = []
+ for location in self.locations:
+ all_hosts.extend(location.get_all_hosts_flat())
+ return all_hosts
diff --git a/pyproject.toml b/pyproject.toml
index 1bfdd9a..65c117c 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -5,4 +5,6 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
+ "pystray",
+ "pillow",
]
diff --git a/uv.lock b/uv.lock
index 589117f..1bfc0ec 100644
--- a/uv.lock
+++ b/uv.lock
@@ -2,7 +2,150 @@ version = 1
revision = 3
requires-python = ">=3.13"
+[[package]]
+name = "pillow"
+version = "11.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" },
+ { url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" },
+ { url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" },
+ { url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" },
+ { url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" },
+ { url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" },
+ { url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" },
+ { url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" },
+ { url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" },
+ { url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" },
+ { url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" },
+ { url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" },
+ { url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" },
+ { url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" },
+ { url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" },
+ { url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" },
+ { url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" },
+ { url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" },
+ { url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" },
+ { url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" },
+ { url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" },
+ { url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" },
+ { url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" },
+ { url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" },
+ { url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" },
+ { url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" },
+]
+
+[[package]]
+name = "pyobjc-core"
+version = "11.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e8/e9/0b85c81e2b441267bca707b5d89f56c2f02578ef8f3eafddf0e0c0b8848c/pyobjc_core-11.1.tar.gz", hash = "sha256:b63d4d90c5df7e762f34739b39cc55bc63dbcf9fb2fb3f2671e528488c7a87fe", size = 974602, upload-time = "2025-06-14T20:56:34.189Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c5/24/12e4e2dae5f85fd0c0b696404ed3374ea6ca398e7db886d4f1322eb30799/pyobjc_core-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:18986f83998fbd5d3f56d8a8428b2f3e0754fd15cef3ef786ca0d29619024f2c", size = 676431, upload-time = "2025-06-14T20:44:49.908Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/79/031492497624de4c728f1857181b06ce8c56444db4d49418fa459cba217c/pyobjc_core-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:8849e78cfe6595c4911fbba29683decfb0bf57a350aed8a43316976ba6f659d2", size = 719330, upload-time = "2025-06-14T20:44:51.621Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/7d/6169f16a0c7ec15b9381f8bf33872baf912de2ef68d96c798ca4c6ee641f/pyobjc_core-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cb9ed17a8d84a312a6e8b665dd22393d48336ea1d8277e7ad20c19a38edf731", size = 667203, upload-time = "2025-06-14T20:44:53.262Z" },
+ { url = "https://files.pythonhosted.org/packages/49/0f/f5ab2b0e57430a3bec9a62b6153c0e79c05a30d77b564efdb9f9446eeac5/pyobjc_core-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:f2455683e807f8541f0d83fbba0f5d9a46128ab0d5cc83ea208f0bec759b7f96", size = 708807, upload-time = "2025-06-14T20:44:54.851Z" },
+]
+
+[[package]]
+name = "pyobjc-framework-cocoa"
+version = "11.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyobjc-core" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/4b/c5/7a866d24bc026f79239b74d05e2cf3088b03263da66d53d1b4cf5207f5ae/pyobjc_framework_cocoa-11.1.tar.gz", hash = "sha256:87df76b9b73e7ca699a828ff112564b59251bb9bbe72e610e670a4dc9940d038", size = 5565335, upload-time = "2025-06-14T20:56:59.683Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/4e/0b/a01477cde2a040f97e226f3e15e5ffd1268fcb6d1d664885a95ba592eca9/pyobjc_framework_cocoa-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:54e93e1d9b0fc41c032582a6f0834befe1d418d73893968f3f450281b11603da", size = 389049, upload-time = "2025-06-14T20:46:53.757Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/e6/64cf2661f6ab7c124d0486ec6d1d01a9bb2838a0d2a46006457d8c5e6845/pyobjc_framework_cocoa-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:fd5245ee1997d93e78b72703be1289d75d88ff6490af94462b564892e9266350", size = 393110, upload-time = "2025-06-14T20:46:54.894Z" },
+ { url = "https://files.pythonhosted.org/packages/33/87/01e35c5a3c5bbdc93d5925366421e10835fcd7b23347b6c267df1b16d0b3/pyobjc_framework_cocoa-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:aede53a1afc5433e1e7d66568cc52acceeb171b0a6005407a42e8e82580b4fc0", size = 392644, upload-time = "2025-06-14T20:46:56.503Z" },
+ { url = "https://files.pythonhosted.org/packages/c1/7c/54afe9ffee547c41e1161691e72067a37ed27466ac71c089bfdcd07ca70d/pyobjc_framework_cocoa-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:1b5de4e1757bb65689d6dc1f8d8717de9ec8587eb0c4831c134f13aba29f9b71", size = 396742, upload-time = "2025-06-14T20:46:57.64Z" },
+]
+
+[[package]]
+name = "pyobjc-framework-quartz"
+version = "11.1"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pyobjc-core" },
+ { name = "pyobjc-framework-cocoa" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/c7/ac/6308fec6c9ffeda9942fef72724f4094c6df4933560f512e63eac37ebd30/pyobjc_framework_quartz-11.1.tar.gz", hash = "sha256:a57f35ccfc22ad48c87c5932818e583777ff7276605fef6afad0ac0741169f75", size = 3953275, upload-time = "2025-06-14T20:58:17.924Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/bd/27/4f4fc0e6a0652318c2844608dd7c41e49ba6006ee5fb60c7ae417c338357/pyobjc_framework_quartz-11.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43a1138280571bbf44df27a7eef519184b5c4183a588598ebaaeb887b9e73e76", size = 216816, upload-time = "2025-06-14T20:53:37.358Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/8a/1d15e42496bef31246f7401aad1ebf0f9e11566ce0de41c18431715aafbc/pyobjc_framework_quartz-11.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b23d81c30c564adf6336e00b357f355b35aad10075dd7e837cfd52a9912863e5", size = 221941, upload-time = "2025-06-14T20:53:38.34Z" },
+ { url = "https://files.pythonhosted.org/packages/32/a8/a3f84d06e567efc12c104799c7fd015f9bea272a75f799eda8b79e8163c6/pyobjc_framework_quartz-11.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:07cbda78b4a8fcf3a2d96e047a2ff01f44e3e1820f46f0f4b3b6d77ff6ece07c", size = 221312, upload-time = "2025-06-14T20:53:39.435Z" },
+ { url = "https://files.pythonhosted.org/packages/76/ef/8c08d4f255bb3efe8806609d1f0b1ddd29684ab0f9ffb5e26d3ad7957b29/pyobjc_framework_quartz-11.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:39d02a3df4b5e3eee1e0da0fb150259476910d2a9aa638ab94153c24317a9561", size = 226353, upload-time = "2025-06-14T20:53:40.655Z" },
+]
+
+[[package]]
+name = "pystray"
+version = "0.19.5"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "pillow" },
+ { name = "pyobjc-framework-quartz", marker = "sys_platform == 'darwin'" },
+ { name = "python-xlib", marker = "sys_platform == 'linux'" },
+ { name = "six" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/5c/64/927a4b9024196a4799eba0180e0ca31568426f258a4a5c90f87a97f51d28/pystray-0.19.5-py2.py3-none-any.whl", hash = "sha256:a0c2229d02cf87207297c22d86ffc57c86c227517b038c0d3c59df79295ac617", size = 49068, upload-time = "2023-09-17T13:44:26.872Z" },
+]
+
+[[package]]
+name = "python-xlib"
+version = "0.33"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/86/f5/8c0653e5bb54e0cbdfe27bf32d41f27bc4e12faa8742778c17f2a71be2c0/python-xlib-0.33.tar.gz", hash = "sha256:55af7906a2c75ce6cb280a584776080602444f75815a7aff4d287bb2d7018b32", size = 269068, upload-time = "2022-12-25T18:53:00.824Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/b8/ff33610932e0ee81ae7f1269c890f697d56ff74b9f5b2ee5d9b7fa2c5355/python_xlib-0.33-py2.py3-none-any.whl", hash = "sha256:c3534038d42e0df2f1392a1b30a15a4ff5fdc2b86cfa94f072bf11b10a164398", size = 182185, upload-time = "2022-12-25T18:52:58.662Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
[[package]]
name = "vpntray"
version = "0.1.0"
source = { virtual = "." }
+dependencies = [
+ { name = "pillow" },
+ { name = "pystray" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "pillow" },
+ { name = "pystray" },
+]
diff --git a/widgets/__init__.py b/widgets/__init__.py
new file mode 100644
index 0000000..501cb30
--- /dev/null
+++ b/widgets/__init__.py
@@ -0,0 +1,11 @@
+from .host_item import HostItem
+from .location_card import ActiveLocationCard, InactiveLocationCard
+from .customer_card import ActiveCustomerCard, InactiveCustomerCard
+
+__all__ = [
+ 'HostItem',
+ 'ActiveLocationCard',
+ 'InactiveLocationCard',
+ 'ActiveCustomerCard',
+ 'InactiveCustomerCard'
+]
\ No newline at end of file
diff --git a/widgets/customer_card.py b/widgets/customer_card.py
new file mode 100644
index 0000000..950f38f
--- /dev/null
+++ b/widgets/customer_card.py
@@ -0,0 +1,74 @@
+import gi
+gi.require_version('Gtk', '3.0')
+from gi.repository import Gtk
+from .location_card import ActiveLocationCard, InactiveLocationCard
+
+
+class ActiveCustomerCard:
+ def __init__(self, customer, callbacks):
+ self.customer = customer
+ self.callbacks = callbacks
+ self.widget = self._create_widget()
+
+ def _create_widget(self):
+ # GNOME-style card container
+ card_frame = Gtk.Frame()
+ card_frame.get_style_context().add_class("card")
+ card_frame.set_shadow_type(Gtk.ShadowType.NONE) # Shadow handled by CSS
+
+ card_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
+ card_frame.add(card_vbox)
+
+ # Customer header
+ customer_label = Gtk.Label()
+ customer_label.set_markup(f"🏢 {self.customer.name}")
+ customer_label.set_halign(Gtk.Align.START)
+ card_vbox.pack_start(customer_label, False, False, 0)
+
+ # Locations section
+ for i, location in enumerate(self.customer.locations):
+ if i > 0: # Add separator between locations
+ separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
+ separator.set_margin_top(8)
+ separator.set_margin_bottom(8)
+ card_vbox.pack_start(separator, False, False, 0)
+
+ location_card = ActiveLocationCard(location, self.customer.name, self.callbacks)
+ card_vbox.pack_start(location_card.widget, False, False, 0)
+
+ return card_frame
+
+
+class InactiveCustomerCard:
+ def __init__(self, customer, callbacks):
+ self.customer = customer
+ self.callbacks = callbacks
+ self.widget = self._create_widget()
+
+ def _create_widget(self):
+ # GNOME-style card container
+ card_frame = Gtk.Frame()
+ card_frame.get_style_context().add_class("card")
+ card_frame.set_shadow_type(Gtk.ShadowType.NONE) # Shadow handled by CSS
+
+ card_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
+ card_frame.add(card_vbox)
+
+ # Customer header - muted
+ customer_label = Gtk.Label()
+ customer_label.set_markup(f"🏢 {self.customer.name}")
+ customer_label.set_halign(Gtk.Align.START)
+ card_vbox.pack_start(customer_label, False, False, 0)
+
+ # Locations section
+ for i, location in enumerate(self.customer.locations):
+ if i > 0: # Add separator between locations
+ separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
+ separator.set_margin_top(8)
+ separator.set_margin_bottom(8)
+ card_vbox.pack_start(separator, False, False, 0)
+
+ location_card = InactiveLocationCard(location, self.customer.name, self.callbacks)
+ card_vbox.pack_start(location_card.widget, False, False, 0)
+
+ return card_frame
\ No newline at end of file
diff --git a/widgets/host_item.py b/widgets/host_item.py
new file mode 100644
index 0000000..980c7a9
--- /dev/null
+++ b/widgets/host_item.py
@@ -0,0 +1,81 @@
+import gi
+gi.require_version('Gtk', '3.0')
+from gi.repository import Gtk
+from models import ServiceType, HostType
+
+
+class HostItem:
+ def __init__(self, host, open_service_callback):
+ self.host = host
+ self.open_service_callback = open_service_callback
+ self.widget = self._create_widget()
+
+ def _create_widget(self):
+ # Clean horizontal layout without borders
+ host_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+
+ # Host header
+ host_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ host_box.pack_start(host_header, False, False, 0)
+
+ # Host type icon
+ type_icons = {
+ HostType.LINUX: "🐧",
+ HostType.WINDOWS: "🪟",
+ HostType.WINDOWS_SERVER: "🖥️",
+ HostType.PROXMOX: "📦",
+ HostType.ESXI: "📦",
+ HostType.ROUTER: "🌐",
+ HostType.SWITCH: "🔗"
+ }
+ icon = type_icons.get(self.host.host_type, "💻")
+
+ icon_label = Gtk.Label(label=icon)
+ host_header.pack_start(icon_label, False, False, 0)
+
+ # Host details - compact single line
+ details_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
+ host_header.pack_start(details_vbox, True, True, 0)
+
+ # Host name with IP inline
+ name_label = Gtk.Label()
+ name_label.set_markup(f"{self.host.name} ({self.host.host_type.value}) - {self.host.ip_address}")
+ name_label.set_halign(Gtk.Align.START)
+ details_vbox.pack_start(name_label, False, False, 0)
+
+ # Services section - compact button row
+ if self.host.services:
+ services_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
+ services_box.set_margin_start(16) # Indent services
+ services_box.set_margin_top(4)
+ host_box.pack_start(services_box, False, False, 0)
+
+ for service in self.host.services:
+ if service.service_type in [ServiceType.WEB_GUI, ServiceType.SSH, ServiceType.RDP]: # Only show launchable services
+ service_btn = Gtk.Button(label=service.service_type.value)
+ service_btn.get_style_context().add_class("suggested-action")
+ service_btn.connect("clicked", lambda btn, s=service: self._on_service_clicked(s))
+ services_box.pack_start(service_btn, False, False, 0)
+
+ # Sub-hosts (VMs) section
+ if self.host.sub_hosts:
+ subhost_label = Gtk.Label()
+ subhost_label.set_markup(f"Virtual Machines ({len(self.host.sub_hosts)})")
+ subhost_label.set_halign(Gtk.Align.START)
+ subhost_label.set_margin_top(8)
+ subhost_label.set_margin_start(16)
+ host_box.pack_start(subhost_label, False, False, 0)
+
+ subhosts_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
+ subhosts_box.set_margin_start(32) # Double indent for VMs
+ host_box.pack_start(subhosts_box, False, False, 0)
+
+ for subhost in self.host.sub_hosts:
+ subhost_item = HostItem(subhost, self.open_service_callback)
+ subhosts_box.pack_start(subhost_item.widget, False, False, 0)
+
+ return host_box
+
+
+ def _on_service_clicked(self, service):
+ self.open_service_callback(service)
\ No newline at end of file
diff --git a/widgets/location_card.py b/widgets/location_card.py
new file mode 100644
index 0000000..b84cb89
--- /dev/null
+++ b/widgets/location_card.py
@@ -0,0 +1,144 @@
+import gi
+gi.require_version('Gtk', '3.0')
+from gi.repository import Gtk
+from .host_item import HostItem
+from models import VPNType
+
+
+class ActiveLocationCard:
+ def __init__(self, location, customer_name, callbacks):
+ self.location = location
+ self.customer_name = customer_name
+ self.callbacks = callbacks
+ self.widget = self._create_widget()
+
+ def _create_widget(self):
+ # Clean card layout - just a box with proper spacing
+ location_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
+
+ # Location header with controls
+ header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
+ location_vbox.pack_start(header_box, False, False, 0)
+
+ # Location info
+ info_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+ header_box.pack_start(info_vbox, True, True, 0)
+
+ # Location name with VPN type
+ location_label = Gtk.Label()
+ location_label.set_markup(f"📍 {self.location.name}")
+ location_label.set_halign(Gtk.Align.START)
+ info_vbox.pack_start(location_label, False, False, 0)
+
+ # VPN type
+ vpn_icons = {
+ VPNType.OPENVPN: "🔒",
+ VPNType.WIREGUARD: "⚡",
+ VPNType.IPSEC: "🛡️"
+ }
+ vpn_icon = vpn_icons.get(self.location.vpn_type, "🔑")
+
+ type_label = Gtk.Label()
+ type_label.set_markup(f"{vpn_icon} {self.location.vpn_type.value} VPN")
+ type_label.set_halign(Gtk.Align.START)
+ info_vbox.pack_start(type_label, False, False, 0)
+
+ # Status and controls
+ controls_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
+ header_box.pack_end(controls_box, False, False, 0)
+
+ # Status
+ status_text = "● Connected" if self.location.connected else "○ Disconnected"
+ status_color = "#4caf50" if self.location.connected else "#999"
+ status_label = Gtk.Label()
+ status_label.set_markup(f"{status_text}")
+ controls_box.pack_start(status_label, False, False, 0)
+
+ # Connect/Disconnect button
+ btn_text = "Disconnect" if self.location.connected else "Connect"
+ connect_btn = Gtk.Button(label=btn_text)
+ if self.location.connected:
+ connect_btn.get_style_context().add_class("destructive-action")
+ else:
+ connect_btn.get_style_context().add_class("suggested-action")
+ connect_btn.connect("clicked", self._on_connect_clicked)
+ controls_box.pack_start(connect_btn, False, False, 0)
+
+ # X button to deactivate (close button style)
+ close_btn = Gtk.Button(label="✕")
+ close_btn.set_tooltip_text("Deactivate location")
+ close_btn.get_style_context().add_class("circular")
+ close_btn.connect("clicked", self._on_deactivate_clicked)
+ controls_box.pack_start(close_btn, False, False, 0)
+
+ # Hosts section if available
+ if self.location.hosts:
+ hosts_label = Gtk.Label()
+ hosts_label.set_markup("Infrastructure")
+ hosts_label.set_halign(Gtk.Align.START)
+ hosts_label.set_margin_top(8)
+ location_vbox.pack_start(hosts_label, False, False, 0)
+
+ # Hosts box with indent
+ hosts_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
+ hosts_box.set_margin_start(16)
+ location_vbox.pack_start(hosts_box, False, False, 0)
+
+ for host in self.location.hosts:
+ host_item = HostItem(host, self.callbacks['open_service'])
+ hosts_box.pack_start(host_item.widget, False, False, 0)
+
+ return location_vbox
+
+ def _on_connect_clicked(self, button):
+ self.callbacks['toggle_connection'](self.location)
+
+ def _on_deactivate_clicked(self, button):
+ self.callbacks['deactivate_location'](self.location, self.customer_name)
+
+
+class InactiveLocationCard:
+ def __init__(self, location, customer_name, callbacks):
+ self.location = location
+ self.customer_name = customer_name
+ self.callbacks = callbacks
+ self.widget = self._create_widget()
+
+ def _create_widget(self):
+ # Clean horizontal layout
+ location_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
+
+ # Location info
+ info_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+ location_hbox.pack_start(info_vbox, True, True, 0)
+
+ # Location name
+ location_label = Gtk.Label()
+ location_label.set_markup(f"📍 {self.location.name}")
+ location_label.set_halign(Gtk.Align.START)
+ info_vbox.pack_start(location_label, False, False, 0)
+
+ # VPN type and host count
+ vpn_icons = {
+ VPNType.OPENVPN: "🔒",
+ VPNType.WIREGUARD: "⚡",
+ VPNType.IPSEC: "🛡️"
+ }
+ vpn_icon = vpn_icons.get(self.location.vpn_type, "🔑")
+ host_count = len(self.location.hosts)
+
+ details_label = Gtk.Label()
+ details_label.set_markup(f"{vpn_icon} {self.location.vpn_type.value} VPN • {host_count} hosts")
+ details_label.set_halign(Gtk.Align.START)
+ info_vbox.pack_start(details_label, False, False, 0)
+
+ # Activate button
+ activate_btn = Gtk.Button(label="Set Active")
+ activate_btn.get_style_context().add_class("suggested-action")
+ activate_btn.connect("clicked", self._on_activate_clicked)
+ location_hbox.pack_end(activate_btn, False, False, 0)
+
+ return location_hbox
+
+ def _on_activate_clicked(self, button):
+ self.callbacks['set_location_active'](self.location, self.customer_name)
\ No newline at end of file