more stuff

This commit is contained in:
2025-09-06 11:11:48 +02:00
parent bae1572d3f
commit a4bf07a3b6
13 changed files with 949 additions and 422 deletions

View File

@@ -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
- 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
```

BIN
current_view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View File

@@ -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
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")

131
example_customer.yaml Normal file
View File

@@ -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

42
init_config.py Normal file
View File

@@ -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()

277
main.py
View File

@@ -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("<i>Current location: Not set</i>")
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("<b>Active Customers</b>")
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("<b>Inactive Customers</b>")
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"<i>📍 Current location: <b>{customer_name} - {location_name}</b></i>"
)
else:
self.current_location_label.set_markup("<i>Current location: Not set</i>")
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()
main()

View File

@@ -7,4 +7,5 @@ requires-python = ">=3.13"
dependencies = [
"pystray",
"pillow",
"pyyaml",
]

19
uv.lock generated
View File

@@ -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" },
]

4
views/__init__.py Normal file
View File

@@ -0,0 +1,4 @@
from .active_view import ActiveView
from .inactive_view import InactiveView
__all__ = ['ActiveView', 'InactiveView']

62
views/active_view.py Normal file
View File

@@ -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("<span alpha='50%'>No active locations</span>")
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()

69
views/inactive_view.py Normal file
View File

@@ -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"<span alpha='50%'>No inactive locations matching '{search_term}'</span>"
)
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()

View File

@@ -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("<b>Cloud Services</b>")
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("<span alpha='60%'><b>Cloud Services</b></span>")
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"<span alpha='60%'><small>• {service.name} ({service.service_type})</small></span>")
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

View File

@@ -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)
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)