more stuff
This commit is contained in:
93
CLAUDE.md
93
CLAUDE.md
@@ -18,7 +18,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- This project uses `uv` for Python package management
|
||||
- `uv sync` - Install dependencies from pyproject.toml
|
||||
- Python 3.13+ required
|
||||
- Dependencies: PyGObject (GTK3), pystray, Pillow
|
||||
- Dependencies: PyGObject (GTK3), pystray, Pillow, PyYAML
|
||||
|
||||
### Configuration Management
|
||||
- `python init_config.py` - Initialize configuration directory with examples
|
||||
- `python data_loader.py --init` - Alternative way to create example files
|
||||
- Configuration location: `~/.vpntray/customers/`
|
||||
- Each customer gets their own YAML file (e.g., `customer_name.yaml`)
|
||||
|
||||
## Code Architecture
|
||||
|
||||
@@ -32,18 +38,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- Includes search functionality across customers, locations, and hosts
|
||||
- HeaderBar for native GNOME look and feel
|
||||
|
||||
**models.py** - Data model definitions using dataclasses
|
||||
- `Service`: Individual services (Web GUI, SSH, RDP, etc.) on hosts
|
||||
**models.py** - Data model definitions using dataclasses and enums
|
||||
- `ServiceType`: Enum for service types (SSH, Web GUI, RDP, VNC, SMB, Database, FTP)
|
||||
- `HostType`: Enum for host types (Linux, Windows, Windows Server, Proxmox, ESXi, Router, Switch)
|
||||
- `VPNType`: Enum for VPN types (OpenVPN, WireGuard, IPSec)
|
||||
- `Service`: Individual services on hosts with type-safe enums
|
||||
- `Host`: Physical/virtual machines with services and sub-hosts (VMs)
|
||||
- `Location`: Customer locations with VPN configurations and host infrastructure
|
||||
- `CustomerService`: Customer's cloud/web services (O365, CRM, etc.)
|
||||
- `Customer`: Top-level entities containing services and locations
|
||||
- Each model includes helper methods for common operations
|
||||
|
||||
**data_loader.py** - Data management layer
|
||||
- `load_customers()`: Returns comprehensive mock data with realistic infrastructure
|
||||
- `save_customers()`: Placeholder for future persistence
|
||||
- Isolates data loading logic from UI components
|
||||
**data_loader.py** - YAML-based data management layer
|
||||
- `load_customers()`: Loads customer configurations from `~/.vpntray/customers/*.yaml` files
|
||||
- `save_customer()`: Saves customer data back to YAML files
|
||||
- `initialize_example_customers()`: Creates example configuration files
|
||||
- Robust parsing with enum conversion and error handling
|
||||
- Falls back to demo data if no configuration files exist
|
||||
|
||||
**widgets/** - Modular UI components using PyGObject
|
||||
- `customer_card.py`: `ActiveCustomerCard` and `InactiveCustomerCard` classes
|
||||
@@ -51,6 +62,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- `host_item.py`: `HostItem` class for displaying hosts and their services
|
||||
- `__init__.py`: Widget exports for clean imports
|
||||
|
||||
**views/** - High-level UI view management
|
||||
- `active_view.py`: `ActiveView` class for displaying active locations
|
||||
- `inactive_view.py`: `InactiveView` class for search results (inactive locations)
|
||||
- `__init__.py`: View exports for clean imports
|
||||
|
||||
**Configuration Files**
|
||||
- `init_config.py`: Helper script to initialize user configuration
|
||||
- `example_customer.yaml`: Complete example showing YAML schema
|
||||
- User config: `~/.vpntray/customers/*.yaml` - One file per customer
|
||||
|
||||
### Key Architecture Patterns
|
||||
|
||||
**Hierarchical Data Structure**: Customer → Location → Host → Service
|
||||
@@ -77,31 +98,63 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- Rich mock data includes hypervisors with VMs, various service types
|
||||
|
||||
### Data Flow
|
||||
1. `data_loader.load_customers()` provides initial customer data with full infrastructure
|
||||
2. Main window loads and filters data based on search terms
|
||||
3. Widget classes create GTK components for customers, locations, and hosts
|
||||
1. `data_loader.load_customers()` loads customer configurations from YAML files in `~/.vpntray/customers/`
|
||||
2. Main window loads and filters data based on search terms (including `*` wildcard for all inactive)
|
||||
3. View classes (`ActiveView`/`InactiveView`) manage display using widget components
|
||||
4. User interactions trigger callbacks that update dataclass attributes
|
||||
5. UI re-renders to reflect state changes
|
||||
5. Changes can be persisted back to YAML files using `save_customer()`
|
||||
6. UI re-renders to reflect state changes with smooth transitions via Gtk.Stack
|
||||
|
||||
### UI Layout Structure
|
||||
- HeaderBar with title and subtitle (GNOME HIG compliance)
|
||||
- Search entry with placeholder text for filtering
|
||||
- Two-column main area with independent scrolling containers
|
||||
- Left column: Active locations with full infrastructure details
|
||||
- Right column: Inactive locations with summary cards and activation buttons
|
||||
- Search entry with placeholder text for filtering (supports `*` wildcard)
|
||||
- Single-view layout using Gtk.Stack for smooth transitions
|
||||
- **Normal mode**: Shows only active locations (full detail view)
|
||||
- **Search mode**: Shows only inactive locations matching search term (activation cards)
|
||||
- GNOME-style cards with CSS theming and proper spacing
|
||||
- System tray integration for minimize-to-tray behavior
|
||||
|
||||
### GTK3/PyGObject Specific Features
|
||||
- CSS styling for GNOME-style cards with borders, shadows, and theming
|
||||
- Native GTK widgets: HeaderBar, SearchEntry, ScrolledWindow
|
||||
- Native GTK widgets: HeaderBar, SearchEntry, ScrolledWindow, Stack
|
||||
- Smooth view transitions using Gtk.Stack with crossfade animation
|
||||
- Proper GNOME HIG compliance for spacing, margins, and layout
|
||||
- Button styling with suggested-action and destructive-action classes
|
||||
- Thread-safe system tray integration using GLib.idle_add
|
||||
|
||||
### Future Extensibility
|
||||
- Replace `load_customers()` with real data source (database, config files, API)
|
||||
- Implement actual VPN connection logic in placeholder methods
|
||||
- Add persistence through `save_customers()` implementation
|
||||
- Extend widget system for additional UI components
|
||||
- Add configuration management for VPN client integration
|
||||
- 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
BIN
current_view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
609
data_loader.py
609
data_loader.py
@@ -1,276 +1,355 @@
|
||||
from models import Customer, CustomerService, Location, Host, Service, ServiceType, HostType, VPNType
|
||||
from typing import List
|
||||
import yaml
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
from models import (
|
||||
Customer, CustomerService, Location, Host, Service,
|
||||
ServiceType, HostType, VPNType
|
||||
)
|
||||
|
||||
|
||||
def get_config_dir() -> Path:
|
||||
"""Get the VPNTray configuration directory path."""
|
||||
home = Path.home()
|
||||
config_dir = home / ".vpntray" / "customers"
|
||||
return config_dir
|
||||
|
||||
|
||||
def ensure_config_dir() -> Path:
|
||||
"""Ensure the configuration directory exists."""
|
||||
config_dir = get_config_dir()
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
return config_dir
|
||||
|
||||
|
||||
def parse_service_type(service_type_str: str) -> ServiceType:
|
||||
"""Convert a string to ServiceType enum, with fallback."""
|
||||
# Map common strings to enum values
|
||||
type_mapping = {
|
||||
"SSH": ServiceType.SSH,
|
||||
"Web GUI": ServiceType.WEB_GUI,
|
||||
"RDP": ServiceType.RDP,
|
||||
"VNC": ServiceType.VNC,
|
||||
"SMB": ServiceType.SMB,
|
||||
"Database": ServiceType.DATABASE,
|
||||
"FTP": ServiceType.FTP
|
||||
}
|
||||
return type_mapping.get(service_type_str, ServiceType.WEB_GUI)
|
||||
|
||||
|
||||
def parse_host_type(host_type_str: str) -> HostType:
|
||||
"""Convert a string to HostType enum, with fallback."""
|
||||
type_mapping = {
|
||||
"Linux": HostType.LINUX,
|
||||
"Windows": HostType.WINDOWS,
|
||||
"Windows Server": HostType.WINDOWS_SERVER,
|
||||
"Proxmox": HostType.PROXMOX,
|
||||
"ESXi": HostType.ESXI,
|
||||
"Router": HostType.ROUTER,
|
||||
"Switch": HostType.SWITCH,
|
||||
}
|
||||
return type_mapping.get(host_type_str, HostType.LINUX)
|
||||
|
||||
|
||||
def parse_vpn_type(vpn_type_str: str) -> VPNType:
|
||||
"""Convert a string to VPNType enum, with fallback."""
|
||||
type_mapping = {
|
||||
"OpenVPN": VPNType.OPENVPN,
|
||||
"WireGuard": VPNType.WIREGUARD,
|
||||
"IPSec": VPNType.IPSEC,
|
||||
}
|
||||
return type_mapping.get(vpn_type_str, VPNType.OPENVPN)
|
||||
|
||||
|
||||
def parse_host(host_data: Dict[str, Any]) -> Host:
|
||||
"""Parse a host from YAML data."""
|
||||
# Parse services
|
||||
services = []
|
||||
if 'services' in host_data:
|
||||
for service_data in host_data['services']:
|
||||
service = Service(
|
||||
name=service_data['name'],
|
||||
service_type=parse_service_type(service_data['service_type']),
|
||||
port=service_data['port']
|
||||
)
|
||||
services.append(service)
|
||||
|
||||
# Create host
|
||||
host = Host(
|
||||
name=host_data['name'],
|
||||
ip_address=host_data['ip_address'],
|
||||
host_type=parse_host_type(host_data['host_type']),
|
||||
description=host_data.get('description', ''),
|
||||
services=services
|
||||
)
|
||||
|
||||
# Parse sub-hosts (VMs) recursively
|
||||
if 'sub_hosts' in host_data:
|
||||
for subhost_data in host_data['sub_hosts']:
|
||||
subhost = parse_host(subhost_data)
|
||||
host.sub_hosts.append(subhost)
|
||||
|
||||
return host
|
||||
|
||||
|
||||
def parse_location(location_data: Dict[str, Any]) -> Location:
|
||||
"""Parse a location from YAML data."""
|
||||
# Parse hosts
|
||||
hosts = []
|
||||
if 'hosts' in location_data:
|
||||
for host_data in location_data['hosts']:
|
||||
host = parse_host(host_data)
|
||||
hosts.append(host)
|
||||
|
||||
# Create location
|
||||
location = Location(
|
||||
name=location_data['name'],
|
||||
vpn_type=parse_vpn_type(location_data['vpn_type']),
|
||||
connected=location_data.get('connected', False),
|
||||
active=location_data.get('active', False),
|
||||
vpn_config=location_data.get('vpn_config', ''),
|
||||
hosts=hosts
|
||||
)
|
||||
|
||||
return location
|
||||
|
||||
|
||||
def parse_customer(yaml_file: Path) -> Customer:
|
||||
"""Parse a customer from a YAML file."""
|
||||
with open(yaml_file, 'r') as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
# Parse customer services
|
||||
services = []
|
||||
if 'services' in data:
|
||||
for service_data in data['services']:
|
||||
service = CustomerService(
|
||||
name=service_data['name'],
|
||||
url=service_data['url'],
|
||||
service_type=service_data['service_type'],
|
||||
description=service_data.get('description', '')
|
||||
)
|
||||
services.append(service)
|
||||
|
||||
# Parse locations
|
||||
locations = []
|
||||
if 'locations' in data:
|
||||
for location_data in data['locations']:
|
||||
location = parse_location(location_data)
|
||||
locations.append(location)
|
||||
|
||||
# Create customer
|
||||
customer = Customer(
|
||||
name=data['name'],
|
||||
services=services,
|
||||
locations=locations
|
||||
)
|
||||
|
||||
return customer
|
||||
|
||||
|
||||
def load_customers() -> List[Customer]:
|
||||
"""Load customer data. Currently returns mock data for demonstration."""
|
||||
|
||||
"""Load all customers from YAML files in the config directory."""
|
||||
config_dir = ensure_config_dir()
|
||||
customers = []
|
||||
|
||||
# Customer 1: TechCorp Solutions
|
||||
techcorp = Customer(name="TechCorp Solutions")
|
||||
# Get all YAML files in the directory
|
||||
yaml_files = list(config_dir.glob("*.yaml")) + \
|
||||
list(config_dir.glob("*.yml"))
|
||||
|
||||
# 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")
|
||||
]
|
||||
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()
|
||||
|
||||
# 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)
|
||||
# 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
131
example_customer.yaml
Normal 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
42
init_config.py
Normal 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
187
main.py
@@ -1,20 +1,21 @@
|
||||
#!/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()
|
||||
@@ -55,7 +56,6 @@ class VPNManagerWindow:
|
||||
screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
|
||||
)
|
||||
|
||||
|
||||
def setup_ui(self):
|
||||
# Use HeaderBar for native GNOME look
|
||||
header_bar = Gtk.HeaderBar()
|
||||
@@ -72,53 +72,36 @@ class VPNManagerWindow:
|
||||
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)
|
||||
|
||||
# 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)
|
||||
# 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)
|
||||
|
||||
# Left column - Active customers
|
||||
left_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||
columns_box.pack_start(left_vbox, True, True, 0)
|
||||
# Get callbacks for views
|
||||
callbacks = self.get_callbacks()
|
||||
|
||||
# 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)
|
||||
# Create active view (shown by default)
|
||||
self.active_view = ActiveView(callbacks)
|
||||
self.view_stack.add_named(self.active_view.widget, "active")
|
||||
|
||||
# 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 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()
|
||||
@@ -156,7 +139,8 @@ class VPNManagerWindow:
|
||||
|
||||
# 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
|
||||
@@ -170,16 +154,15 @@ class VPNManagerWindow:
|
||||
'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 = []
|
||||
@@ -189,32 +172,29 @@ class VPNManagerWindow:
|
||||
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.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()
|
||||
|
||||
@@ -224,8 +204,12 @@ class VPNManagerWindow:
|
||||
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):
|
||||
@@ -235,24 +219,52 @@ 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
|
||||
@@ -260,23 +272,23 @@ class VPNManagerWindow:
|
||||
# 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
|
||||
|
||||
@@ -289,6 +301,7 @@ class VPNManagerWindow:
|
||||
self.filtered_customers.append(customer)
|
||||
break
|
||||
else:
|
||||
# Empty search - show all customers
|
||||
self.filtered_customers = self.customers.copy()
|
||||
|
||||
self.render_customers()
|
||||
@@ -301,10 +314,12 @@ class VPNManagerWindow:
|
||||
|
||||
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
|
||||
|
||||
@@ -7,4 +7,5 @@ requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"pystray",
|
||||
"pillow",
|
||||
"pyyaml",
|
||||
]
|
||||
|
||||
19
uv.lock
generated
19
uv.lock
generated
@@ -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
4
views/__init__.py
Normal 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
62
views/active_view.py
Normal 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
69
views/inactive_view.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
def _on_set_current_clicked(self, button):
|
||||
self.callbacks['set_current_location'](self.location, self.customer_name)
|
||||
Reference in New Issue
Block a user