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 0000000..7b66925 Binary files /dev/null and b/current_view.png differ 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