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