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 - This project uses `uv` for Python package management
- `uv sync` - Install dependencies from pyproject.toml - `uv sync` - Install dependencies from pyproject.toml
- Python 3.13+ required - 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 ## 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 - Includes search functionality across customers, locations, and hosts
- HeaderBar for native GNOME look and feel - HeaderBar for native GNOME look and feel
**models.py** - Data model definitions using dataclasses **models.py** - Data model definitions using dataclasses and enums
- `Service`: Individual services (Web GUI, SSH, RDP, etc.) on hosts - `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) - `Host`: Physical/virtual machines with services and sub-hosts (VMs)
- `Location`: Customer locations with VPN configurations and host infrastructure - `Location`: Customer locations with VPN configurations and host infrastructure
- `CustomerService`: Customer's cloud/web services (O365, CRM, etc.) - `CustomerService`: Customer's cloud/web services (O365, CRM, etc.)
- `Customer`: Top-level entities containing services and locations - `Customer`: Top-level entities containing services and locations
- Each model includes helper methods for common operations - Each model includes helper methods for common operations
**data_loader.py** - Data management layer **data_loader.py** - YAML-based data management layer
- `load_customers()`: Returns comprehensive mock data with realistic infrastructure - `load_customers()`: Loads customer configurations from `~/.vpntray/customers/*.yaml` files
- `save_customers()`: Placeholder for future persistence - `save_customer()`: Saves customer data back to YAML files
- Isolates data loading logic from UI components - `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 **widgets/** - Modular UI components using PyGObject
- `customer_card.py`: `ActiveCustomerCard` and `InactiveCustomerCard` classes - `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 - `host_item.py`: `HostItem` class for displaying hosts and their services
- `__init__.py`: Widget exports for clean imports - `__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 ### Key Architecture Patterns
**Hierarchical Data Structure**: Customer → Location → Host → Service **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 - Rich mock data includes hypervisors with VMs, various service types
### Data Flow ### Data Flow
1. `data_loader.load_customers()` provides initial customer data with full infrastructure 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 2. Main window loads and filters data based on search terms (including `*` wildcard for all inactive)
3. Widget classes create GTK components for customers, locations, and hosts 3. View classes (`ActiveView`/`InactiveView`) manage display using widget components
4. User interactions trigger callbacks that update dataclass attributes 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 ### UI Layout Structure
- HeaderBar with title and subtitle (GNOME HIG compliance) - HeaderBar with title and subtitle (GNOME HIG compliance)
- Search entry with placeholder text for filtering - Search entry with placeholder text for filtering (supports `*` wildcard)
- Two-column main area with independent scrolling containers - Single-view layout using Gtk.Stack for smooth transitions
- Left column: Active locations with full infrastructure details - **Normal mode**: Shows only active locations (full detail view)
- Right column: Inactive locations with summary cards and activation buttons - **Search mode**: Shows only inactive locations matching search term (activation cards)
- GNOME-style cards with CSS theming and proper spacing - GNOME-style cards with CSS theming and proper spacing
- System tray integration for minimize-to-tray behavior - System tray integration for minimize-to-tray behavior
### GTK3/PyGObject Specific Features ### GTK3/PyGObject Specific Features
- CSS styling for GNOME-style cards with borders, shadows, and theming - 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 - Proper GNOME HIG compliance for spacing, margins, and layout
- Button styling with suggested-action and destructive-action classes - Button styling with suggested-action and destructive-action classes
- Thread-safe system tray integration using GLib.idle_add - Thread-safe system tray integration using GLib.idle_add
### Future Extensibility ### Future Extensibility
- Replace `load_customers()` with real data source (database, config files, API)
- Implement actual VPN connection logic in placeholder methods - Implement actual VPN connection logic in placeholder methods
- Add persistence through `save_customers()` implementation - Add real-time VPN status monitoring and automatic reconnection
- Extend widget system for additional UI components - Extend YAML schema for additional VPN configuration options
- Add configuration management for VPN client integration - 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 import yaml
from typing import List 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]: 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 = [] customers = []
# Customer 1: TechCorp Solutions # Get all YAML files in the directory
techcorp = Customer(name="TechCorp Solutions") yaml_files = list(config_dir.glob("*.yaml")) + \
list(config_dir.glob("*.yml"))
# TechCorp's cloud services if not yaml_files:
techcorp.services = [ # No customer files found, initialize with examples
CustomerService("Office 365", "https://portal.office.com", "Email & Office"), print(f"No customer files found in {config_dir}")
CustomerService("Pascom Cloud PBX", "https://techcorp.pascom.cloud", "Phone System"), print("Run 'python data_loader.py --init' to create example customer files")
CustomerService("Salesforce CRM", "https://techcorp.salesforce.com", "CRM") return get_demo_customers()
]
# TechCorp's main office location # Load each customer file
main_office = Location( for yaml_file in yaml_files:
name="Main Office", try:
vpn_type=VPNType.OPENVPN, customer = parse_customer(yaml_file)
connected=True, customers.append(customer)
active=True, print(f"Loaded customer: {customer.name} from {yaml_file.name}")
vpn_config="/etc/openvpn/techcorp-main.ovpn" except Exception as e:
) print(f"Error loading {yaml_file}: {e}")
# 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)
return customers return customers
def save_customers(customers: List[Customer]) -> None: def save_customer(customer: Customer, filename: str = None) -> None:
"""Save customer data. Currently a placeholder.""" """Save a customer to a YAML file."""
# TODO: Implement actual persistence (JSON file, database, etc.) config_dir = ensure_config_dir()
pass
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()

187
main.py
View File

@@ -1,20 +1,21 @@
#!/usr/bin/env python3 #!/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 import gi
gi.require_version('Gtk', '3.0') 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: class VPNManagerWindow:
def __init__(self): def __init__(self):
self.customers = load_customers() self.customers = load_customers()
self.filtered_customers = self.customers.copy() self.filtered_customers = self.customers.copy()
self.current_location = None # Track user's current location
# Create main window # Create main window
self.window = Gtk.Window() self.window = Gtk.Window()
@@ -55,7 +56,6 @@ class VPNManagerWindow:
screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
) )
def setup_ui(self): def setup_ui(self):
# Use HeaderBar for native GNOME look # Use HeaderBar for native GNOME look
header_bar = Gtk.HeaderBar() header_bar = Gtk.HeaderBar()
@@ -72,53 +72,36 @@ class VPNManagerWindow:
main_vbox.set_margin_bottom(12) main_vbox.set_margin_bottom(12)
self.window.add(main_vbox) 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 # Search bar with SearchEntry
self.search_entry = Gtk.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) self.search_entry.connect("search-changed", self.filter_customers)
main_vbox.pack_start(self.search_entry, False, False, 0) main_vbox.pack_start(self.search_entry, False, False, 0)
# Clean two-column layout like GNOME Control Center # Create a stack to switch between views
columns_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=24) self.view_stack = Gtk.Stack()
main_vbox.pack_start(columns_box, True, True, 0) 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)
# Left column - Active customers # Get callbacks for views
left_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) callbacks = self.get_callbacks()
columns_box.pack_start(left_vbox, True, True, 0)
# Simple label header # Create active view (shown by default)
active_label = Gtk.Label() self.active_view = ActiveView(callbacks)
active_label.set_markup("<b>Active Customers</b>") self.view_stack.add_named(self.active_view.widget, "active")
active_label.set_halign(Gtk.Align.START)
left_vbox.pack_start(active_label, False, False, 0)
# Clean scrolled window without borders # Create inactive view (shown when searching)
active_scrolled = Gtk.ScrolledWindow() self.inactive_view = InactiveView(callbacks)
active_scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) self.view_stack.add_named(self.inactive_view.widget, "inactive")
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)
# Render initial data # Render initial data
self.render_customers() self.render_customers()
@@ -156,7 +139,8 @@ class VPNManagerWindow:
# Also provide a right-click menu # Also provide a right-click menu
menu = pystray.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) pystray.MenuItem("Quit", self.quit_app)
) )
self.tray_icon.menu = menu self.tray_icon.menu = menu
@@ -170,16 +154,15 @@ class VPNManagerWindow:
'toggle_connection': self.toggle_connection, 'toggle_connection': self.toggle_connection,
'set_location_active': self.set_location_active, 'set_location_active': self.set_location_active,
'deactivate_location': self.deactivate_location, 'deactivate_location': self.deactivate_location,
'set_current_location': self.set_current_location,
'open_service': self.open_service, 'open_service': self.open_service,
'open_customer_service': self.open_customer_service 'open_customer_service': self.open_customer_service
} }
def render_customers(self): def render_customers(self):
# Clear existing content # Check if we're in search mode
for child in self.active_box.get_children(): search_term = self.search_entry.get_text().strip()
child.destroy() is_searching = bool(search_term)
for child in self.inactive_box.get_children():
child.destroy()
# Separate customers with active and inactive locations # Separate customers with active and inactive locations
customers_with_active = [] customers_with_active = []
@@ -189,32 +172,29 @@ class VPNManagerWindow:
active_locations = customer.get_active_locations() active_locations = customer.get_active_locations()
inactive_locations = customer.get_inactive_locations() inactive_locations = customer.get_inactive_locations()
# Prepare active locations (shown when not searching)
if active_locations: if active_locations:
from models import Customer
customer_data = Customer(name=customer.name) customer_data = Customer(name=customer.name)
customer_data.services = customer.services customer_data.services = customer.services
customer_data.locations = active_locations customer_data.locations = active_locations
customers_with_active.append(customer_data) customers_with_active.append(customer_data)
# Prepare inactive locations (shown when searching)
if inactive_locations: if inactive_locations:
from models import Customer
customer_data = Customer(name=customer.name) customer_data = Customer(name=customer.name)
customer_data.services = customer.services customer_data.services = customer.services
customer_data.locations = inactive_locations customer_data.locations = inactive_locations
customers_with_inactive.append(customer_data) customers_with_inactive.append(customer_data)
# Get callbacks for widgets # Update views based on mode
callbacks = self.get_callbacks() if is_searching:
# Search mode: Switch to inactive view and update it
# Render active customers using widget classes self.view_stack.set_visible_child_name("inactive")
for customer in customers_with_active: self.inactive_view.update(customers_with_inactive, search_term)
customer_card = ActiveCustomerCard(customer, callbacks) else:
self.active_box.pack_start(customer_card.widget, False, False, 0) # Normal mode: Switch to active view and update it
self.view_stack.set_visible_child_name("active")
# Render inactive customers using widget classes self.active_view.update(customers_with_active)
for customer in customers_with_inactive:
customer_card = InactiveCustomerCard(customer, callbacks)
self.inactive_box.pack_start(customer_card.widget, False, False, 0)
self.window.show_all() self.window.show_all()
@@ -224,8 +204,12 @@ class VPNManagerWindow:
target_location = customer.get_location_by_name(location.name) target_location = customer.get_location_by_name(location.name)
if target_location: if target_location:
target_location.active = True 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 break
# Clear search and return to active view
self.search_entry.set_text("")
self.render_customers() self.render_customers()
def deactivate_location(self, location, customer_name): def deactivate_location(self, location, customer_name):
@@ -235,24 +219,52 @@ class VPNManagerWindow:
if target_location: if target_location:
target_location.active = False target_location.active = False
target_location.connected = False # Disconnect when deactivating 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 break
self.render_customers() 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): def filter_customers(self, entry):
search_term = entry.get_text().lower() search_term = entry.get_text().strip()
if search_term:
# 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 = [] self.filtered_customers = []
for customer in self.customers: for customer in self.customers:
# Check if search term matches customer name # 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) self.filtered_customers.append(customer)
continue continue
# Check customer services # Check customer services
if any(search_term in service.name.lower() or if any(search_term_lower in service.name.lower() or
search_term in service.url.lower() or search_term_lower in service.url.lower() or
search_term in service.service_type.lower() search_term_lower in service.service_type.lower()
for service in customer.services): for service in customer.services):
self.filtered_customers.append(customer) self.filtered_customers.append(customer)
continue continue
@@ -260,23 +272,23 @@ class VPNManagerWindow:
# Check locations and their hosts # Check locations and their hosts
for location in customer.locations: for location in customer.locations:
# Check location name # Check location name
if search_term in location.name.lower(): if search_term_lower in location.name.lower():
self.filtered_customers.append(customer) self.filtered_customers.append(customer)
break break
# Check hosts and their services in this location # Check hosts and their services in this location
def search_hosts(hosts): def search_hosts(hosts):
for host in hosts: for host in hosts:
if (search_term in host.name.lower() or if (search_term_lower in host.name.lower() or
search_term in host.ip_address.lower() or search_term_lower in host.ip_address.lower() or
search_term in host.host_type.value.lower() or search_term_lower in host.host_type.value.lower() or
search_term in host.description.lower()): search_term_lower in host.description.lower()):
return True return True
# Check host services # Check host services
if any(search_term in service.name.lower() or if any(search_term_lower in service.name.lower() or
search_term in str(service.port).lower() or search_term_lower in str(service.port).lower() or
search_term in service.service_type.value.lower() search_term_lower in service.service_type.value.lower()
for service in host.services): for service in host.services):
return True return True
@@ -289,6 +301,7 @@ class VPNManagerWindow:
self.filtered_customers.append(customer) self.filtered_customers.append(customer)
break break
else: else:
# Empty search - show all customers
self.filtered_customers = self.customers.copy() self.filtered_customers = self.customers.copy()
self.render_customers() self.render_customers()
@@ -301,10 +314,12 @@ class VPNManagerWindow:
def open_service(self, service): def open_service(self, service):
# Get the host IP from context - this would need to be passed properly in a real implementation # 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): 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): def show_window_from_tray(self, _icon=None, _item=None):
# Use GLib.idle_add to safely call GTK functions from the tray thread # Use GLib.idle_add to safely call GTK functions from the tray thread

View File

@@ -7,4 +7,5 @@ requires-python = ">=3.13"
dependencies = [ dependencies = [
"pystray", "pystray",
"pillow", "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" }, { 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]] [[package]]
name = "six" name = "six"
version = "1.17.0" version = "1.17.0"
@@ -142,10 +159,12 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "pillow" }, { name = "pillow" },
{ name = "pystray" }, { name = "pystray" },
{ name = "pyyaml" },
] ]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "pillow" }, { name = "pillow" },
{ name = "pystray" }, { 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) customer_label.set_halign(Gtk.Align.START)
card_vbox.pack_start(customer_label, False, False, 0) 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 # Locations section
for i, location in enumerate(self.customer.locations): for i, location in enumerate(self.customer.locations):
if i > 0: # Add separator between locations if i > 0: # Add separator between locations
@@ -60,6 +80,26 @@ class InactiveCustomerCard:
customer_label.set_halign(Gtk.Align.START) customer_label.set_halign(Gtk.Align.START)
card_vbox.pack_start(customer_label, False, False, 0) 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 # Locations section
for i, location in enumerate(self.customer.locations): for i, location in enumerate(self.customer.locations):
if i > 0: # Add separator between locations if i > 0: # Add separator between locations

View File

@@ -132,13 +132,25 @@ class InactiveLocationCard:
details_label.set_halign(Gtk.Align.START) details_label.set_halign(Gtk.Align.START)
info_vbox.pack_start(details_label, False, False, 0) 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 button
activate_btn = Gtk.Button(label="Set Active") activate_btn = Gtk.Button(label="Set Active")
activate_btn.get_style_context().add_class("suggested-action") activate_btn.get_style_context().add_class("suggested-action")
activate_btn.connect("clicked", self._on_activate_clicked) 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 return location_hbox
def _on_activate_clicked(self, button): 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)