From a4bf07a3b6328711fdb05fa2928c117a9ea00c5d Mon Sep 17 00:00:00 2001 From: Alexander Thiess Date: Sat, 6 Sep 2025 11:11:48 +0200 Subject: [PATCH] more stuff --- CLAUDE.md | 93 ++++-- current_view.png | Bin 0 -> 77982 bytes data_loader.py | 617 ++++++++++++++++++++++----------------- example_customer.yaml | 131 +++++++++ init_config.py | 42 +++ main.py | 277 +++++++++--------- pyproject.toml | 1 + uv.lock | 19 ++ views/__init__.py | 4 + views/active_view.py | 62 ++++ views/inactive_view.py | 69 +++++ widgets/customer_card.py | 40 +++ widgets/location_card.py | 16 +- 13 files changed, 949 insertions(+), 422 deletions(-) create mode 100644 current_view.png create mode 100644 example_customer.yaml create mode 100644 init_config.py create mode 100644 views/__init__.py create mode 100644 views/active_view.py create mode 100644 views/inactive_view.py diff --git a/CLAUDE.md b/CLAUDE.md index b186c1f..b26b025 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,13 @@ 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 +- Dependencies: PyGObject (GTK3), pystray, Pillow, PyYAML + +### Configuration Management +- `python init_config.py` - Initialize configuration directory with examples +- `python data_loader.py --init` - Alternative way to create example files +- Configuration location: `~/.vpntray/customers/` +- Each customer gets their own YAML file (e.g., `customer_name.yaml`) ## Code Architecture @@ -32,18 +38,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Includes search functionality across customers, locations, and hosts - HeaderBar for native GNOME look and feel -**models.py** - Data model definitions using dataclasses -- `Service`: Individual services (Web GUI, SSH, RDP, etc.) on hosts +**models.py** - Data model definitions using dataclasses and enums +- `ServiceType`: Enum for service types (SSH, Web GUI, RDP, VNC, SMB, Database, FTP) +- `HostType`: Enum for host types (Linux, Windows, Windows Server, Proxmox, ESXi, Router, Switch) +- `VPNType`: Enum for VPN types (OpenVPN, WireGuard, IPSec) +- `Service`: Individual services on hosts with type-safe enums - `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()`: Returns comprehensive mock data with realistic infrastructure -- `save_customers()`: Placeholder for future persistence -- Isolates data loading logic from UI components +**data_loader.py** - YAML-based data management layer +- `load_customers()`: Loads customer configurations from `~/.vpntray/customers/*.yaml` files +- `save_customer()`: Saves customer data back to YAML files +- `initialize_example_customers()`: Creates example configuration files +- Robust parsing with enum conversion and error handling +- Falls back to demo data if no configuration files exist **widgets/** - Modular UI components using PyGObject - `customer_card.py`: `ActiveCustomerCard` and `InactiveCustomerCard` classes @@ -51,6 +62,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - `host_item.py`: `HostItem` class for displaying hosts and their services - `__init__.py`: Widget exports for clean imports +**views/** - High-level UI view management +- `active_view.py`: `ActiveView` class for displaying active locations +- `inactive_view.py`: `InactiveView` class for search results (inactive locations) +- `__init__.py`: View exports for clean imports + +**Configuration Files** +- `init_config.py`: Helper script to initialize user configuration +- `example_customer.yaml`: Complete example showing YAML schema +- User config: `~/.vpntray/customers/*.yaml` - One file per customer + ### Key Architecture Patterns **Hierarchical Data Structure**: Customer → Location → Host → Service @@ -77,31 +98,63 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - Rich mock data includes hypervisors with VMs, various service types ### Data Flow -1. `data_loader.load_customers()` provides initial customer data with full infrastructure -2. Main window loads and filters data based on search terms -3. Widget classes create GTK components for customers, locations, and hosts +1. `data_loader.load_customers()` loads customer configurations from YAML files in `~/.vpntray/customers/` +2. Main window loads and filters data based on search terms (including `*` wildcard for all inactive) +3. View classes (`ActiveView`/`InactiveView`) manage display using widget components 4. User interactions trigger callbacks that update dataclass attributes -5. UI re-renders to reflect state changes +5. Changes can be persisted back to YAML files using `save_customer()` +6. UI re-renders to reflect state changes with smooth transitions via Gtk.Stack ### UI Layout Structure - 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 +- Search entry with placeholder text for filtering (supports `*` wildcard) +- Single-view layout using Gtk.Stack for smooth transitions +- **Normal mode**: Shows only active locations (full detail view) +- **Search mode**: Shows only inactive locations matching search term (activation cards) - 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 +- Native GTK widgets: HeaderBar, SearchEntry, ScrolledWindow, Stack +- Smooth view transitions using Gtk.Stack with crossfade animation - 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 -- Extend widget system for additional UI components -- Add configuration management for VPN client integration \ No newline at end of file +- Add real-time VPN status monitoring and automatic reconnection +- Extend YAML schema for additional VPN configuration options +- Add import/export functionality for customer configurations +- Implement configuration validation and error reporting +- Add support for additional VPN clients and protocols +- Extend widget system for additional UI components (settings, logs, etc.) + +### YAML Configuration Schema +Customer files in `~/.vpntray/customers/` follow this structure: +```yaml +name: Customer Name +services: + - name: Service Name + url: https://service.url + service_type: Service Category + description: Optional description +locations: + - name: Location Name + vpn_type: OpenVPN|WireGuard|IPSec + vpn_config: /path/to/config/file + active: true|false + connected: true|false + hosts: + - name: Host Name + ip_address: IP Address + host_type: Linux|Windows|etc + description: Optional description + services: + - name: Service Name + service_type: SSH|Web GUI|RDP|etc + port: Port Number + sub_hosts: # Optional VMs/containers + - # Same structure as hosts +``` \ No newline at end of file diff --git a/current_view.png b/current_view.png new file mode 100644 index 0000000000000000000000000000000000000000..7b66925718b93e396ab7aa2ef92b787e8ad0dbda GIT binary patch literal 77982 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 a(47mFQ^A^VsNLf#H3;F$743cI~Pn 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

~uf>I5%z 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 Jgkp9p_FtX{){OuF literal 0 HcmV?d00001 diff --git a/data_loader.py b/data_loader.py index d54f382..bc48ee6 100644 --- a/data_loader.py +++ b/data_loader.py @@ -1,276 +1,355 @@ -from models import Customer, CustomerService, Location, Host, Service, ServiceType, HostType, VPNType -from typing import List +import yaml +from pathlib import Path +from typing import List, Dict, Any +from models import ( + Customer, CustomerService, Location, Host, Service, + ServiceType, HostType, VPNType +) + + +def get_config_dir() -> Path: + """Get the VPNTray configuration directory path.""" + home = Path.home() + config_dir = home / ".vpntray" / "customers" + return config_dir + + +def ensure_config_dir() -> Path: + """Ensure the configuration directory exists.""" + config_dir = get_config_dir() + config_dir.mkdir(parents=True, exist_ok=True) + return config_dir + + +def parse_service_type(service_type_str: str) -> ServiceType: + """Convert a string to ServiceType enum, with fallback.""" + # Map common strings to enum values + type_mapping = { + "SSH": ServiceType.SSH, + "Web GUI": ServiceType.WEB_GUI, + "RDP": ServiceType.RDP, + "VNC": ServiceType.VNC, + "SMB": ServiceType.SMB, + "Database": ServiceType.DATABASE, + "FTP": ServiceType.FTP + } + return type_mapping.get(service_type_str, ServiceType.WEB_GUI) + + +def parse_host_type(host_type_str: str) -> HostType: + """Convert a string to HostType enum, with fallback.""" + type_mapping = { + "Linux": HostType.LINUX, + "Windows": HostType.WINDOWS, + "Windows Server": HostType.WINDOWS_SERVER, + "Proxmox": HostType.PROXMOX, + "ESXi": HostType.ESXI, + "Router": HostType.ROUTER, + "Switch": HostType.SWITCH, + } + return type_mapping.get(host_type_str, HostType.LINUX) + + +def parse_vpn_type(vpn_type_str: str) -> VPNType: + """Convert a string to VPNType enum, with fallback.""" + type_mapping = { + "OpenVPN": VPNType.OPENVPN, + "WireGuard": VPNType.WIREGUARD, + "IPSec": VPNType.IPSEC, + } + return type_mapping.get(vpn_type_str, VPNType.OPENVPN) + + +def parse_host(host_data: Dict[str, Any]) -> Host: + """Parse a host from YAML data.""" + # Parse services + services = [] + if 'services' in host_data: + for service_data in host_data['services']: + service = Service( + name=service_data['name'], + service_type=parse_service_type(service_data['service_type']), + port=service_data['port'] + ) + services.append(service) + + # Create host + host = Host( + name=host_data['name'], + ip_address=host_data['ip_address'], + host_type=parse_host_type(host_data['host_type']), + description=host_data.get('description', ''), + services=services + ) + + # Parse sub-hosts (VMs) recursively + if 'sub_hosts' in host_data: + for subhost_data in host_data['sub_hosts']: + subhost = parse_host(subhost_data) + host.sub_hosts.append(subhost) + + return host + + +def parse_location(location_data: Dict[str, Any]) -> Location: + """Parse a location from YAML data.""" + # Parse hosts + hosts = [] + if 'hosts' in location_data: + for host_data in location_data['hosts']: + host = parse_host(host_data) + hosts.append(host) + + # Create location + location = Location( + name=location_data['name'], + vpn_type=parse_vpn_type(location_data['vpn_type']), + connected=location_data.get('connected', False), + active=location_data.get('active', False), + vpn_config=location_data.get('vpn_config', ''), + hosts=hosts + ) + + return location + + +def parse_customer(yaml_file: Path) -> Customer: + """Parse a customer from a YAML file.""" + with open(yaml_file, 'r') as f: + data = yaml.safe_load(f) + + # Parse customer services + services = [] + if 'services' in data: + for service_data in data['services']: + service = CustomerService( + name=service_data['name'], + url=service_data['url'], + service_type=service_data['service_type'], + description=service_data.get('description', '') + ) + services.append(service) + + # Parse locations + locations = [] + if 'locations' in data: + for location_data in data['locations']: + location = parse_location(location_data) + locations.append(location) + + # Create customer + customer = Customer( + name=data['name'], + services=services, + locations=locations + ) + + return customer def load_customers() -> List[Customer]: - """Load customer data. Currently returns mock data for demonstration.""" - + """Load all customers from YAML files in the config directory.""" + config_dir = ensure_config_dir() 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") - ] - - # TechCorp's main office location - main_office = Location( - name="Main Office", - vpn_type=VPNType.OPENVPN, - connected=True, - active=True, - vpn_config="/etc/openvpn/techcorp-main.ovpn" - ) - - # 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) - ] - ) - - # 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) - ] - ), - 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) - + + # Get all YAML files in the directory + yaml_files = list(config_dir.glob("*.yaml")) + \ + list(config_dir.glob("*.yml")) + + if not yaml_files: + # No customer files found, initialize with examples + print(f"No customer files found in {config_dir}") + print("Run 'python data_loader.py --init' to create example customer files") + return get_demo_customers() + + # Load each customer file + for yaml_file in yaml_files: + try: + customer = parse_customer(yaml_file) + customers.append(customer) + print(f"Loaded customer: {customer.name} from {yaml_file.name}") + except Exception as e: + print(f"Error loading {yaml_file}: {e}") + return customers -def save_customers(customers: List[Customer]) -> None: - """Save customer data. Currently a placeholder.""" - # TODO: Implement actual persistence (JSON file, database, etc.) - pass \ No newline at end of file +def save_customer(customer: Customer, filename: str = None) -> None: + """Save a customer to a YAML file.""" + config_dir = ensure_config_dir() + + if filename is None: + # Generate filename from customer name + filename = customer.name.lower().replace(' ', '_') + '.yaml' + + filepath = config_dir / filename + + # Convert customer to dictionary + data = { + 'name': customer.name, + 'services': [ + { + 'name': service.name, + 'url': service.url, + 'service_type': service.service_type, + 'description': service.description + } + for service in customer.services + ], + 'locations': [] + } + + # Convert locations + for location in customer.locations: + location_data = { + 'name': location.name, + 'vpn_type': location.vpn_type.value, + 'vpn_config': location.vpn_config, + 'active': location.active, + 'connected': location.connected, + 'hosts': [] + } + + # Convert hosts + def convert_host(host): + host_data = { + 'name': host.name, + 'ip_address': host.ip_address, + 'host_type': host.host_type.value, + 'description': host.description, + 'services': [ + { + 'name': service.name, + 'service_type': service.service_type.value, + 'port': service.port + } + for service in host.services + ] + } + + if host.sub_hosts: + host_data['sub_hosts'] = [convert_host( + subhost) for subhost in host.sub_hosts] + + return host_data + + for host in location.hosts: + location_data['hosts'].append(convert_host(host)) + + data['locations'].append(location_data) + + # Write to file + with open(filepath, 'w') as f: + yaml.dump(data, f, default_flow_style=False, sort_keys=False) + + print(f"Saved customer to {filepath}") + + +def get_demo_customers() -> List[Customer]: + """Return demo customers for when no config files exist.""" + # Return a minimal demo customer + demo_customer = Customer(name="Demo Customer") + + demo_customer.services = [ + CustomerService( + name="Demo Portal", + url="https://demo.example.com", + service_type="Web Portal", + description="Demo web portal" + ) + ] + + demo_location = Location( + name="Demo Location", + vpn_type=VPNType.OPENVPN, + connected=False, + active=True, + vpn_config="/etc/openvpn/demo.ovpn" + ) + + demo_host = Host( + name="DEMO-01", + ip_address="10.0.0.1", + host_type=HostType.LINUX, + description="Demo server", + services=[ + Service("SSH", ServiceType.SSH, 22), + Service("Web", ServiceType.WEB_GUI, 80) + ] + ) + + demo_location.hosts = [demo_host] + demo_customer.locations = [demo_location] + + return [demo_customer] + + +def initialize_example_customers(): + """Create example customer YAML files in the config directory.""" + config_dir = ensure_config_dir() + + # Create TechCorp example + techcorp_file = config_dir / "techcorp_solutions.yaml" + if not techcorp_file.exists(): + # Read from our example file + example_file = Path(__file__).parent / "example_customer.yaml" + if example_file.exists(): + with open(example_file, 'r') as f: + content = f.read() + with open(techcorp_file, 'w') as f: + f.write(content) + print(f"Created example: {techcorp_file}") + + # Create a simpler example + simple_file = config_dir / "simple_customer.yaml" + if not simple_file.exists(): + simple_yaml = """name: Simple Customer + +services: + - name: Company Website + url: https://simple.example.com + service_type: Web Portal + description: Main company website + +locations: + - name: Main Office + vpn_type: WireGuard + vpn_config: /etc/wireguard/simple.conf + active: false + connected: false + + hosts: + - name: SERVER-01 + ip_address: 192.168.1.10 + host_type: Linux + description: Main server + services: + - name: SSH + service_type: SSH + port: 22 + - name: Web Interface + service_type: Web GUI + port: 443 +""" + with open(simple_file, 'w') as f: + f.write(simple_yaml) + print(f"Created example: {simple_file}") + + print(f"\nExample customer files created in: {config_dir}") + print("You can now edit these files or create new ones following the same format.") + + +# Allow running this file directly to initialize examples +if __name__ == "__main__": + import sys + if len(sys.argv) > 1 and sys.argv[1] == "--init": + initialize_example_customers() + else: + # Test loading + customers = load_customers() + for customer in customers: + print(f"\nLoaded: {customer.name}") + print(f" Services: {len(customer.services)}") + print(f" Locations: {len(customer.locations)}") + for location in customer.locations: + print(f" - {location.name}: {len(location.hosts)} hosts") diff --git a/example_customer.yaml b/example_customer.yaml new file mode 100644 index 0000000..fd908ae --- /dev/null +++ b/example_customer.yaml @@ -0,0 +1,131 @@ +# Example customer YAML configuration +name: TechCorp Solutions + +# Cloud/web services available regardless of VPN connection +services: + - name: Office 365 + url: https://portal.office.com + service_type: Email & Office + description: Microsoft Office suite and email + + - name: Pascom Cloud PBX + url: https://techcorp.pascom.cloud + service_type: Phone System + description: Cloud-based phone system + + - name: Salesforce CRM + url: https://techcorp.salesforce.com + service_type: CRM + description: Customer relationship management + +# Customer locations with VPN configurations +locations: + - name: Main Office + vpn_type: OpenVPN + vpn_config: /etc/openvpn/techcorp-main.ovpn + active: true + connected: true + + # Hosts at this location + hosts: + - name: PVE-01 + ip_address: 192.168.1.10 + host_type: Proxmox + description: Main virtualization server + services: + - name: Web Interface + service_type: Web GUI + port: 8006 + - name: SSH + service_type: SSH + port: 22 + + # VMs running on this host + sub_hosts: + - name: DC-01 + ip_address: 192.168.1.20 + host_type: Windows Server + description: Domain Controller + services: + - name: RDP + service_type: RDP + port: 3389 + - name: Admin Web + service_type: Web GUI + port: 8080 + + - name: FILE-01 + ip_address: 192.168.1.21 + host_type: Linux + description: File Server (Samba) + services: + - name: SSH + service_type: SSH + port: 22 + - name: SMB Share + service_type: SMB + port: 445 + - name: Web Panel + service_type: Web GUI + port: 9000 + + - name: DB-01 + ip_address: 192.168.1.22 + host_type: Linux + description: PostgreSQL Database + services: + - name: SSH + service_type: SSH + port: 22 + - name: PostgreSQL + service_type: Database + port: 5432 + - name: pgAdmin + service_type: Web GUI + port: 5050 + + - name: FW-01 + ip_address: 192.168.1.1 + host_type: Router + description: pfSense Firewall/Router + services: + - name: Web Interface + service_type: Web GUI + port: 443 + - name: SSH + service_type: SSH + port: 22 + + - name: SW-01 + ip_address: 192.168.1.2 + host_type: Switch + description: Managed Switch + services: + - name: Web Interface + service_type: Web GUI + port: 80 + - name: SSH + service_type: SSH + port: 22 + + - name: Branch Office + vpn_type: WireGuard + vpn_config: /etc/wireguard/techcorp-branch.conf + active: false + connected: false + + hosts: + - name: BRANCH-01 + ip_address: 10.10.1.10 + host_type: Linux + description: Branch office server + services: + - name: SSH + service_type: SSH + port: 22 + - name: File Share + service_type: SMB + port: 445 + - name: Local Web + service_type: Web GUI + port: 8080 \ No newline at end of file diff --git a/init_config.py b/init_config.py new file mode 100644 index 0000000..12816d9 --- /dev/null +++ b/init_config.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" +VPNTray Configuration Initializer + +This script helps set up the initial configuration directory and example customer files. +""" + +import sys +from data_loader import initialize_example_customers, get_config_dir + + +def main(): + """Initialize VPNTray configuration with example customers.""" + config_dir = get_config_dir() + + print("VPNTray Configuration Initializer") + print("=" * 35) + print(f"Configuration directory: {config_dir}") + print() + + try: + initialize_example_customers() + print() + print("✅ Configuration initialized successfully!") + print() + print("Next steps:") + print("1. Edit the YAML files in the config directory to match your setup") + print("2. Add more customer files as needed (one per customer)") + print("3. Start the VPN Manager: python main.py") + print() + print("YAML file format:") + print("- Each customer gets their own .yaml/.yml file") + print("- File names don't matter (use descriptive names)") + print("- See example_customer.yaml for the complete schema") + + except Exception as e: + print(f"❌ Error initializing configuration: {e}") + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/main.py b/main.py index b8704a1..b29df67 100644 --- a/main.py +++ b/main.py @@ -1,38 +1,39 @@ #!/usr/bin/env python3 +from views import ActiveView, InactiveView +from data_loader import load_customers +from models import Customer +from PIL import Image, ImageDraw +import pystray +import threading +import sys +from gi.repository import Gtk, Gdk, GLib import gi gi.require_version('Gtk', '3.0') -from gi.repository import Gtk, Gdk, GLib -import sys -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: def __init__(self): self.customers = load_customers() self.filtered_customers = self.customers.copy() - + self.current_location = None # Track user's current location + # Create main window self.window = Gtk.Window() self.window.set_title("VPN Manager") self.window.set_default_size(1200, 750) self.window.connect("delete-event", self.quit_app_from_close) self.window.connect("window-state-event", self.on_window_state_event) - + # Set up minimal CSS for GNOME-style cards self.setup_css() - + # Create UI self.setup_ui() self.setup_system_tray() - + # Start hidden self.window.hide() - + def setup_css(self): """Minimal CSS for GNOME-style cards""" css_provider = Gtk.CssProvider() @@ -47,15 +48,14 @@ class VPNManagerWindow: } """ css_provider.load_from_data(css.encode()) - + # Apply CSS to default screen screen = Gdk.Screen.get_default() style_context = Gtk.StyleContext() style_context.add_provider_for_screen( screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) - - + def setup_ui(self): # Use HeaderBar for native GNOME look header_bar = Gtk.HeaderBar() @@ -63,7 +63,7 @@ class VPNManagerWindow: 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) @@ -71,58 +71,41 @@ class VPNManagerWindow: main_vbox.set_margin_top(12) main_vbox.set_margin_bottom(12) self.window.add(main_vbox) - + + # Current location display + self.current_location_label = Gtk.Label() + self.current_location_label.set_markup("Current location: Not set") + self.current_location_label.set_halign(Gtk.Align.CENTER) + self.current_location_label.set_margin_bottom(8) + main_vbox.pack_start(self.current_location_label, False, False, 0) + # Search bar with SearchEntry self.search_entry = Gtk.SearchEntry() - self.search_entry.set_placeholder_text("Search customers, locations, or hosts...") + self.search_entry.set_placeholder_text( + "Search customers, locations, or hosts... (* for all)") self.search_entry.connect("search-changed", self.filter_customers) main_vbox.pack_start(self.search_entry, False, False, 0) + + # Create a stack to switch between views + self.view_stack = Gtk.Stack() + self.view_stack.set_transition_type(Gtk.StackTransitionType.CROSSFADE) + self.view_stack.set_transition_duration(200) + main_vbox.pack_start(self.view_stack, True, True, 0) - # 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) + # Get callbacks for views + callbacks = self.get_callbacks() - # Left column - Active customers - left_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) - columns_box.pack_start(left_vbox, True, True, 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) - - # 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=12) - active_scrolled.add(self.active_box) - - # Right column - Inactive customers - right_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) - columns_box.pack_start(right_vbox, True, True, 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) - - # 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=12) - inactive_scrolled.add(self.inactive_box) + # Create active view (shown by default) + self.active_view = ActiveView(callbacks) + self.view_stack.add_named(self.active_view.widget, "active") + # Create inactive view (shown when searching) + self.inactive_view = InactiveView(callbacks) + self.view_stack.add_named(self.inactive_view.widget, "inactive") + # Render initial data self.render_customers() - + def setup_system_tray(self): # Create a simple icon for the system tray def create_icon(): @@ -130,7 +113,7 @@ class VPNManagerWindow: 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) @@ -141,7 +124,7 @@ class VPNManagerWindow: 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 @@ -150,84 +133,85 @@ class VPNManagerWindow: create_icon(), "VPN Manager - Double-click to open" ) - + # Set direct click action self.tray_icon.default_action = self.show_window_from_tray - + # Also provide a right-click menu menu = pystray.Menu( - pystray.MenuItem("Open VPN Manager", self.show_window_from_tray, default=True), + pystray.MenuItem("Open VPN Manager", + self.show_window_from_tray, default=True), pystray.MenuItem("Quit", self.quit_app) ) self.tray_icon.menu = menu - + # Start tray icon in separate thread threading.Thread(target=self.tray_icon.run, daemon=True).start() - + 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, + 'set_current_location': self.set_current_location, 'open_service': self.open_service, 'open_customer_service': self.open_customer_service } - + def render_customers(self): - # Clear existing content - for child in self.active_box.get_children(): - child.destroy() - for child in self.inactive_box.get_children(): - child.destroy() - + # Check if we're in search mode + search_term = self.search_entry.get_text().strip() + is_searching = bool(search_term) + # Separate customers with active and inactive locations customers_with_active = [] customers_with_inactive = [] - + for customer in self.filtered_customers: active_locations = customer.get_active_locations() inactive_locations = customer.get_inactive_locations() - + + # Prepare active locations (shown when not searching) if 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) - + + # Prepare inactive locations (shown when searching) if inactive_locations: - from models import Customer customer_data = Customer(name=customer.name) - customer_data.services = customer.services + customer_data.services = customer.services customer_data.locations = inactive_locations customers_with_inactive.append(customer_data) - - # Get callbacks for widgets - callbacks = self.get_callbacks() - - # 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: - customer_card = InactiveCustomerCard(customer, callbacks) - self.inactive_box.pack_start(customer_card.widget, False, False, 0) - + + # Update views based on mode + if is_searching: + # Search mode: Switch to inactive view and update it + self.view_stack.set_visible_child_name("inactive") + self.inactive_view.update(customers_with_inactive, search_term) + else: + # Normal mode: Switch to active view and update it + self.view_stack.set_visible_child_name("active") + self.active_view.update(customers_with_active) + self.window.show_all() - + def set_location_active(self, location, customer_name): for customer in self.customers: if customer.name == customer_name: target_location = customer.get_location_by_name(location.name) if target_location: target_location.active = True - print(f"Mock: Setting {customer.name} - {target_location.name} as active") + print( + f"Mock: Setting {customer.name} - {target_location.name} as active") break + + # Clear search and return to active view + self.search_entry.set_text("") self.render_customers() - + def deactivate_location(self, location, customer_name): for customer in self.customers: if customer.name == customer_name: @@ -235,106 +219,137 @@ class VPNManagerWindow: if target_location: target_location.active = False target_location.connected = False # Disconnect when deactivating - print(f"Mock: Deactivating {customer.name} - {target_location.name}") + print( + f"Mock: Deactivating {customer.name} - {target_location.name}") break self.render_customers() + def set_current_location(self, location, customer_name): + """Set the user's current location.""" + for customer in self.customers: + if customer.name == customer_name: + target_location = customer.get_location_by_name(location.name) + if target_location: + self.current_location = (customer.name, target_location.name) + print(f"Current location set to: {customer.name} - {target_location.name}") + self.update_current_location_display() + break + + def update_current_location_display(self): + """Update the current location display label.""" + if self.current_location: + customer_name, location_name = self.current_location + self.current_location_label.set_markup( + f"📍 Current location: {customer_name} - {location_name}" + ) + else: + self.current_location_label.set_markup("Current location: Not set") + def filter_customers(self, entry): - search_term = entry.get_text().lower() - if search_term: + search_term = entry.get_text().strip() + + # Check for wildcard - show all customers + if search_term == "*": + self.filtered_customers = self.customers.copy() + elif search_term: + # Normal search logic + search_term_lower = search_term.lower() self.filtered_customers = [] for customer in self.customers: # Check if search term matches customer name - if search_term in customer.name.lower(): + if search_term_lower in customer.name.lower(): self.filtered_customers.append(customer) 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() + if any(search_term_lower in service.name.lower() or + search_term_lower in service.url.lower() or + search_term_lower 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(): + if search_term_lower in location.name.lower(): self.filtered_customers.append(customer) break - + # 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()): + if (search_term_lower in host.name.lower() or + search_term_lower in host.ip_address.lower() or + search_term_lower in host.host_type.value.lower() or + search_term_lower 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() + if any(search_term_lower in service.name.lower() or + search_term_lower in str(service.port).lower() or + search_term_lower 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: + # Empty search - show all customers self.filtered_customers = self.customers.copy() - + self.render_customers() - + def toggle_connection(self, location): location.connected = not location.connected status = "connected to" if location.connected else "disconnected from" print(f"Mock: {status} {location.name} via {location.vpn_type.value}") self.render_customers() - + 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}") - + print( + f"Mock: Opening {service.service_type.value} service: {service.name} on port {service.port}") + def open_customer_service(self, customer_service): - print(f"Mock: Opening customer service: {customer_service.name} at {customer_service.url}") - + print( + f"Mock: Opening customer service: {customer_service.name} at {customer_service.url}") + 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 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_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) - + def run(self): self.window.show_all() Gtk.main() @@ -346,4 +361,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/pyproject.toml b/pyproject.toml index 65c117c..d9b13eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,4 +7,5 @@ requires-python = ">=3.13" dependencies = [ "pystray", "pillow", + "pyyaml", ] diff --git a/uv.lock b/uv.lock index 1bfc0ec..4b921b5 100644 --- a/uv.lock +++ b/uv.lock @@ -126,6 +126,23 @@ 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 = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -142,10 +159,12 @@ source = { virtual = "." } dependencies = [ { name = "pillow" }, { name = "pystray" }, + { name = "pyyaml" }, ] [package.metadata] requires-dist = [ { name = "pillow" }, { name = "pystray" }, + { name = "pyyaml" }, ] diff --git a/views/__init__.py b/views/__init__.py new file mode 100644 index 0000000..30d1617 --- /dev/null +++ b/views/__init__.py @@ -0,0 +1,4 @@ +from .active_view import ActiveView +from .inactive_view import InactiveView + +__all__ = ['ActiveView', 'InactiveView'] \ No newline at end of file diff --git a/views/active_view.py b/views/active_view.py new file mode 100644 index 0000000..20f2778 --- /dev/null +++ b/views/active_view.py @@ -0,0 +1,62 @@ +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +from widgets import ActiveCustomerCard + + +class ActiveView: + """View for displaying active customer locations.""" + + def __init__(self, callbacks): + self.callbacks = callbacks + self.widget = self._create_widget() + + def _create_widget(self): + """Create the main container for active locations.""" + # Main container + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + + # Scrolled window for content + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled.set_shadow_type(Gtk.ShadowType.NONE) + vbox.pack_start(scrolled, True, True, 0) + + # Content box + self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + scrolled.add(self.content_box) + + return vbox + + def update(self, customers): + """Update the view with new customer data. + + Args: + customers: List of Customer objects with active locations to display + """ + # Clear existing content + for child in self.content_box.get_children(): + child.destroy() + + if customers: + # Add customer cards + for customer in customers: + customer_card = ActiveCustomerCard(customer, self.callbacks) + self.content_box.pack_start(customer_card.widget, False, False, 0) + else: + # Show empty state message + no_active_label = Gtk.Label() + no_active_label.set_markup("No active locations") + no_active_label.set_margin_top(20) + self.content_box.pack_start(no_active_label, False, False, 0) + + self.content_box.show_all() + + def set_visible(self, visible): + """Set visibility of the entire view.""" + self.widget.set_visible(visible) + + def clear(self): + """Clear all content from the view.""" + for child in self.content_box.get_children(): + child.destroy() \ No newline at end of file diff --git a/views/inactive_view.py b/views/inactive_view.py new file mode 100644 index 0000000..34845b3 --- /dev/null +++ b/views/inactive_view.py @@ -0,0 +1,69 @@ +import gi +gi.require_version('Gtk', '3.0') +from gi.repository import Gtk +from widgets import InactiveCustomerCard + + +class InactiveView: + """View for displaying inactive customer locations (search results).""" + + def __init__(self, callbacks): + self.callbacks = callbacks + self.widget = self._create_widget() + self.current_search = "" + + def _create_widget(self): + """Create the main container for inactive/search results.""" + # Main container + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + + # Scrolled window for content + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled.set_shadow_type(Gtk.ShadowType.NONE) + vbox.pack_start(scrolled, True, True, 0) + + # Content box + self.content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) + scrolled.add(self.content_box) + + return vbox + + def update(self, customers, search_term=""): + """Update the view with search results. + + Args: + customers: List of Customer objects with inactive locations to display + search_term: The current search term + """ + self.current_search = search_term + + # Clear existing content + for child in self.content_box.get_children(): + child.destroy() + + if customers: + # Add customer cards + for customer in customers: + customer_card = InactiveCustomerCard(customer, self.callbacks) + self.content_box.pack_start(customer_card.widget, False, False, 0) + else: + # Show no results message + if search_term: + no_results_label = Gtk.Label() + no_results_label.set_markup( + f"No inactive locations matching '{search_term}'" + ) + no_results_label.set_margin_top(20) + self.content_box.pack_start(no_results_label, False, False, 0) + + self.content_box.show_all() + + def set_visible(self, visible): + """Set visibility of the entire view.""" + self.widget.set_visible(visible) + + def clear(self): + """Clear all content from the view.""" + for child in self.content_box.get_children(): + child.destroy() \ No newline at end of file diff --git a/widgets/customer_card.py b/widgets/customer_card.py index 950f38f..45c746b 100644 --- a/widgets/customer_card.py +++ b/widgets/customer_card.py @@ -25,6 +25,26 @@ class ActiveCustomerCard: customer_label.set_halign(Gtk.Align.START) card_vbox.pack_start(customer_label, False, False, 0) + # Customer services section + if self.customer.services: + services_label = Gtk.Label() + services_label.set_markup("Cloud Services") + services_label.set_halign(Gtk.Align.START) + services_label.set_margin_top(8) + card_vbox.pack_start(services_label, False, False, 0) + + # Services box with indent + services_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8) + services_box.set_margin_start(16) + services_box.set_margin_bottom(8) + card_vbox.pack_start(services_box, False, False, 0) + + for service in self.customer.services: + service_btn = Gtk.Button(label=service.name) + service_btn.get_style_context().add_class("suggested-action") + service_btn.connect("clicked", lambda btn, s=service: self.callbacks['open_customer_service'](s)) + services_box.pack_start(service_btn, False, False, 0) + # Locations section for i, location in enumerate(self.customer.locations): if i > 0: # Add separator between locations @@ -60,6 +80,26 @@ class InactiveCustomerCard: customer_label.set_halign(Gtk.Align.START) card_vbox.pack_start(customer_label, False, False, 0) + # Customer services section - list format for inactive + if self.customer.services: + services_label = Gtk.Label() + services_label.set_markup("Cloud Services") + services_label.set_halign(Gtk.Align.START) + services_label.set_margin_top(8) + card_vbox.pack_start(services_label, False, False, 0) + + # Services list with indent + services_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + services_vbox.set_margin_start(16) + services_vbox.set_margin_bottom(8) + card_vbox.pack_start(services_vbox, False, False, 0) + + for service in self.customer.services: + service_label = Gtk.Label() + service_label.set_markup(f"• {service.name} ({service.service_type})") + service_label.set_halign(Gtk.Align.START) + services_vbox.pack_start(service_label, False, False, 0) + # Locations section for i, location in enumerate(self.customer.locations): if i > 0: # Add separator between locations diff --git a/widgets/location_card.py b/widgets/location_card.py index b84cb89..5f9167a 100644 --- a/widgets/location_card.py +++ b/widgets/location_card.py @@ -132,13 +132,25 @@ class InactiveLocationCard: details_label.set_halign(Gtk.Align.START) info_vbox.pack_start(details_label, False, False, 0) + # Button box for multiple buttons + button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + location_hbox.pack_end(button_box, False, False, 0) + + # Set as Current button + current_btn = Gtk.Button(label="Set as Current") + current_btn.connect("clicked", self._on_set_current_clicked) + button_box.pack_start(current_btn, 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) + button_box.pack_start(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 + self.callbacks['set_location_active'](self.location, self.customer_name) + + def _on_set_current_clicked(self, button): + self.callbacks['set_current_location'](self.location, self.customer_name) \ No newline at end of file