stuff
101
CLAUDE.md
@@ -32,19 +32,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
**main.py** - Main GUI application entry point
|
||||
- `VPNManagerWindow` class: Primary PyGObject/GTK3-based GUI application
|
||||
- Implements single-view layout with Gtk.Stack for smooth transitions
|
||||
- Two-column layout: active customers (left) vs inactive customers (right)
|
||||
- Features system tray integration using `pystray`
|
||||
- Uses GNOME-style theming with CSS styling for cards
|
||||
- Includes advanced search functionality with wildcard support (`*`)
|
||||
- Uses GNOME-style theming with CSS card styling
|
||||
- Includes comprehensive logging system with collapsible log view
|
||||
- HeaderBar for native GNOME look and feel
|
||||
- Current location tracking and display
|
||||
- Current location tracking and enhanced display with network topology
|
||||
|
||||
**models.py** - Type-safe 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 and port numbers
|
||||
- `Host`: Physical/virtual machines with services and recursive sub-hosts (VMs)
|
||||
- `Host`: Physical/virtual machines with multiple IP addresses, services, and recursive 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
|
||||
@@ -66,8 +66,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
**widgets/** - Modular UI components using PyGObject
|
||||
- `customer_card.py`: `ActiveCustomerCard` and `InactiveCustomerCard` classes
|
||||
- Active cards: Interactive buttons for customer services and full location details
|
||||
- Inactive cards: Read-only service lists and location activation buttons
|
||||
- **Compact tree-like design**: Hierarchical layout with expand/collapse arrows
|
||||
- **Card styling**: Customer cards contain location subcards with proper visual hierarchy
|
||||
- **Multi-column layout**: Fixed-width columns for proper alignment (name, IP, actions)
|
||||
- **Service action icons**: Direct access buttons for SSH, RDP, Web GUI with tooltips
|
||||
- **Multiple IP support**: Display primary IP with hover tooltip showing all addresses
|
||||
- Active cards: Full interaction with connection controls and infrastructure details
|
||||
- Inactive cards: Activation buttons and current location setting
|
||||
- `location_card.py`: `ActiveLocationCard` and `InactiveLocationCard` classes
|
||||
- Active cards: Connection controls, deactivation (X button), and infrastructure details
|
||||
- Inactive cards: Current location setting and activation buttons
|
||||
@@ -76,6 +81,19 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- Service buttons for direct access to SSH, Web GUI, RDP services
|
||||
- `__init__.py`: Widget exports for clean imports
|
||||
|
||||
**services/** - VPN and credential management (modular architecture)
|
||||
- `vpn_manager.py`: NetworkManager (nmcli) integration with .ovpn file support
|
||||
- `passbolt_client.py`: Passbolt CLI client for secure credential management
|
||||
- `connection_manager.py`: High-level orchestrator combining VPN and credentials
|
||||
- Support for flexible credential storage (direct username/password or Passbolt UUIDs)
|
||||
|
||||
**views/** - Comprehensive logging system
|
||||
- `log_view.py`: `LogView` class with collapsible interface
|
||||
- **Command logging**: Real-time capture of nmcli and system command output
|
||||
- **Color-coded levels**: Info, success, warning, error with visual distinction
|
||||
- **Auto-scroll**: Automatic scrolling to latest entries with manual override
|
||||
- **Expandable/collapsible**: Bottom panel that can be hidden to save space
|
||||
|
||||
**Configuration Files**
|
||||
- `init_config.py`: Helper script to initialize user configuration with examples
|
||||
- `example_customer.yaml`: Complete example showing YAML schema with all features
|
||||
@@ -93,13 +111,16 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- **Active/Inactive**: Locations can be activated for VPN management
|
||||
- **Current Location**: User's physical location (separate from VPN connections)
|
||||
- **Connection State**: VPN connection status independent of location activation
|
||||
- **Network Topology**: Each location includes internal networks and external endpoints
|
||||
- **Credential Management**: Flexible credential storage (direct or Passbolt UUID)
|
||||
- Automatic UI updates based on state changes with immediate feedback
|
||||
|
||||
**Single-View UI Architecture with Stack Navigation**:
|
||||
- Uses `Gtk.Stack` for smooth view transitions with crossfade animation
|
||||
- **Normal mode**: Shows only active locations (full detail view)
|
||||
- **Search mode**: Shows only inactive locations (activation and current location setting)
|
||||
- Clean visual separation with no overlapping or confusing dual-column layouts
|
||||
**Two-Column Layout Architecture**:
|
||||
- **Left column**: Active customers with full location details and infrastructure
|
||||
- **Right column**: Inactive customers available for activation
|
||||
- **Compact design**: Tree-like hierarchy with proper indentation and alignment
|
||||
- **Real-time filtering**: Search affects both columns simultaneously
|
||||
- **Dynamic reorganization**: Customers move between columns based on location state
|
||||
|
||||
**Widget-Based Component System**:
|
||||
- Modular widget classes handle their own GTK widget creation and event handling
|
||||
@@ -118,7 +139,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
The application tracks two distinct location concepts:
|
||||
- **Current Location**: Where the user physically is (set via "Set as Current" button)
|
||||
- **Active Locations**: Locations available for VPN connections
|
||||
- Current location is displayed prominently above the search bar
|
||||
|
||||
**Enhanced Current Location Display**:
|
||||
- **Prominent info box** with customer name, location, and VPN type
|
||||
- **Host count summary** with VM breakdown (e.g., "3 hosts (7 total with VMs)")
|
||||
- **Collapsible infrastructure section** with detailed host and VM information
|
||||
- **Network topology display**: Internal networks and external endpoints
|
||||
- **Visual host type icons** (🐧 Linux, 🪟 Windows, 📦 Proxmox, 🌐 Router, etc.)
|
||||
- **Hierarchical VM display** with service counts and multiple IP addresses
|
||||
- **Multi-interface support**: Hosts can have multiple IP addresses (firewalls, routers)
|
||||
- Users can set current location from inactive location cards without activating VPN
|
||||
|
||||
### Search and Discovery Features
|
||||
@@ -142,16 +171,24 @@ The application tracks two distinct location concepts:
|
||||
|
||||
### UI Layout Structure
|
||||
|
||||
**Modern Single-View Design**:
|
||||
- HeaderBar with title and subtitle (GNOME HIG compliance)
|
||||
- Current location display (centered, prominent)
|
||||
- Search entry with comprehensive placeholder text (supports `*` wildcard)
|
||||
- Single-view layout using Gtk.Stack for smooth transitions
|
||||
- **Normal mode**: Active locations with full interaction (connections, services, infrastructure)
|
||||
- **Search mode**: Inactive locations with activation and current location setting
|
||||
**Modern Two-Column Design**:
|
||||
- HeaderBar with title and current location display
|
||||
- **Enhanced current location info box** with network topology and collapsible infrastructure
|
||||
- Search entry with real-time filtering across both columns
|
||||
- **Left column**: Active customers with full interaction (connections, services, infrastructure)
|
||||
- **Right column**: Inactive customers with activation and current location setting
|
||||
- **Compact tree-like cards** with customer cards containing location subcards
|
||||
- **Fixed-width columns**: Proper alignment of host names, IP addresses, and action icons
|
||||
- **Collapsible log view**: Bottom panel for command output and system logs
|
||||
- GNOME-style cards with CSS theming, proper spacing, and visual hierarchy
|
||||
- System tray integration for minimize-to-tray behavior
|
||||
|
||||
**Customer Card Features**:
|
||||
- **Active cards**: Start expanded, show full location details and services
|
||||
- **Inactive cards**: Start collapsed to save space during search
|
||||
- **Location count badges**: Show number of locations in parentheses
|
||||
- **Smooth expand/collapse**: Click arrow buttons to toggle content visibility
|
||||
|
||||
### GTK3/PyGObject Specific Features
|
||||
|
||||
- **CSS styling**: GNOME-style cards with borders, shadows, and adaptive theming
|
||||
@@ -188,12 +225,28 @@ locations:
|
||||
- name: Location Name
|
||||
vpn_type: OpenVPN|WireGuard|IPSec
|
||||
vpn_config: /path/to/config/file
|
||||
active: true|false # Available for VPN management
|
||||
connected: true|false # Current VPN connection status
|
||||
|
||||
# VPN credentials (three options):
|
||||
# Option 1: Dictionary with username/password
|
||||
vpn_credentials:
|
||||
username: vpnuser
|
||||
password: password123
|
||||
|
||||
# Option 2: Passbolt UUID (for future implementation)
|
||||
# vpn_credentials: "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
# Option 3: Omit or set to null if no credentials needed
|
||||
# vpn_credentials: null
|
||||
|
||||
# Note: active and connected are runtime state (not stored in config)
|
||||
|
||||
# Network topology information
|
||||
external_addresses: [vpn.domain.com, backup.domain.com] # VPN endpoints
|
||||
networks: [192.168.1.0/24, 10.0.1.0/24] # Internal networks
|
||||
|
||||
hosts:
|
||||
- name: Host Name
|
||||
ip_address: IP Address
|
||||
ip_addresses: [192.168.1.10, 10.0.1.10] # Multiple interfaces supported
|
||||
host_type: Linux|Windows|Windows Server|Proxmox|ESXi|Router|Switch
|
||||
description: Optional description
|
||||
|
||||
@@ -204,7 +257,7 @@ locations:
|
||||
|
||||
sub_hosts: # Optional VMs/containers (recursive structure)
|
||||
- name: VM Name
|
||||
ip_address: VM IP
|
||||
ip_addresses: [192.168.1.20] # VMs can also have multiple IPs
|
||||
host_type: Linux|Windows|Windows Server
|
||||
services: # Same structure as parent host
|
||||
- name: Service Name
|
||||
|
||||
1
assets/icons/debian.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Layer_1" x="0" y="0" version="1.1" viewBox="50.32 0 411.28 512.1"><style>.st0{fill:#a80030}</style><path d="M296.2 270.5c-8.5.1 1.6 4.4 12.7 6.1 3.1-2.4 5.8-4.8 8.3-7.2-7 1.7-14 1.8-21 1.1m45.5-11.3c5.1-7 8.7-14.6 10-22.5-1.1 5.6-4.2 10.5-7.1 15.6-15.9 10-1.5-5.9 0-12-17 21.5-2.3 12.9-2.9 18.9m16.8-43.8c1-15.3-3-10.4-4.4-4.6 1.6.8 2.9 10.8 4.4 4.6M264 6.6c4.5.8 9.8 1.4 9 2.5 5-1.1 6.1-2.1-9-2.5m9 2.5-3.2.7 3-.3z" class="st0"/><path d="M414.2 221.2c.5 13.7-4 20.4-8.1 32.2l-7.3 3.7c-6 11.7.6 7.4-3.7 16.7-9.4 8.3-28.4 26.1-34.5 27.7-4.5-.1 3-5.3 4-7.3-12.5 8.6-10.1 12.9-29.2 18.2l-.6-1.2c-47.3 22.3-113-21.8-112.1-82-.5 3.8-1.4 2.9-2.5 4.4-2.4-31 14.3-62 42.5-74.7 27.6-13.7 60-8.1 79.8 10.4-10.9-14.2-32.5-29.3-58.1-27.9-25.1.4-48.6 16.3-56.4 33.7-12.9 8.1-14.4 31.2-20 35.4-7.5 55.4 14.2 79.4 50.9 107.5 5.8 3.9 1.6 4.5 2.4 7.5-12.2-5.7-23.4-14.3-32.6-24.9 4.9 7.1 10.1 14.1 16.9 19.5-11.5-3.9-26.9-27.9-31.4-28.9 19.8 35.5 80.5 62.3 112.3 49-14.7.5-33.4.3-49.9-5.8-6.9-3.6-16.4-11-14.7-12.3 43.3 16.2 88.1 12.3 125.6-17.8 9.5-7.4 20-20.1 23-20.2-4.5 6.8.8 3.3-2.7 9.3 9.5-15.3-4.1-6.2 9.8-26.5l5.2 7.1c-1.9-12.7 15.8-28.2 14-48.3 4.1-6.2 4.5 6.6.2 20.8 6-15.7 1.6-18.2 3.1-31.2 1.7 4.4 3.8 9 5 13.6-3.9-15.2 4-25.6 6-34.4-1.9-.9-6 6.7-7-11.2.1-7.8 2.2-4.1 3-6-1.5-.9-5.5-6.9-8-18.3 1.8-2.7 4.7 7 7.1 7.4-1.5-9.1-4.2-16.1-4.3-23-7-14.7-2.5 2-8.2-6.3-7.5-23.4 6.2-5.4 7.1-16 11.3 16.4 17.8 41.9 20.8 52.5-2.3-12.9-5.9-25.3-10.4-37.4 3.4 1.4-5.6-26.5 4.5-8-10.7-39.4-45.9-76.3-78.2-93.6 4 3.6 9 8.2 7.2 8.9-16.1-9.6-13.3-10.3-15.6-14.4-13.1-5.3-14 .4-22.6 0-24.7-13.5-29.4-12.1-52.2-20.3l1 4.8c-16.4-5.4-19.1 2.1-36.7 0-1.1-.8 5.7-3 11.2-3.8-15.8 2.1-15.1-3.1-30.6.6C243 7.7 247 6 251.1 3.7c-12.9.8-30.8 7.5-25.3 1.4-21 9.4-58.4 22.6-79.4 42.2l-.7-4.4c-9.6 11.5-41.9 34.4-44.5 49.4l-2.6.6c-5 8.5-8.2 18.1-12.2 26.8-6.5 11.1-9.6 4.3-8.7 6-12.9 26.1-19.2 48-24.8 66 3.9 5.9.1 35.4 1.6 59-6.5 116.6 81.9 229.9 178.4 256 14.1 5.1 35.2 4.9 53.1 5.4-21.1-6-23.8-3.2-44.4-10.4-14.8-7-18.1-15-28.6-24.1l4.2 7.4c-20.6-7.3-12-9-28.8-14.3l4.4-5.8c-6.7-.5-17.7-11.3-20.7-17.2l-7.3.3c-8.8-10.8-13.5-18.6-13.1-24.7l-2.4 4.2c-2.7-4.6-32.3-40.6-16.9-32.2-2.9-2.6-6.7-4.2-10.8-11.7l3.1-3.6c-7.4-9.5-13.6-21.7-13.1-25.8 3.9 5.3 6.7 6.3 9.4 7.2-18.7-46.4-19.7-2.6-33.9-47.2l3-.2c-2.3-3.5-3.7-7.2-5.5-10.9l1.3-13c-13.5-15.6-3.8-66.1-1.8-93.9 1.3-11.3 11.2-23.3 18.7-42.1l-4.6-.8c8.8-15.3 50-61.3 69.1-58.9 9.2-11.6-1.8 0-3.6-3 20.3-21 26.7-14.9 40.4-18.6 14.8-8.8-12.7 3.4-5.7-3.3 25.6-6.5 18.1-14.8 51.5-18.2 3.5 2-8.2 3.1-11.1 5.7 21.3-10.4 67.4-8 97.3 5.8 34.7 16.2 73.8 64.2 75.3 109.4l1.8.5c-.9 17.9 2.7 38.7-3.6 57.8z" class="st0"/><path d="m203.6 282.2-1.2 5.9c5.6 7.6 10 15.8 17.1 21.7-5.1-10-8.9-14.1-15.9-27.6m13.1-.6c-3-3.3-4.7-7.2-6.7-11.1 1.9 6.9 5.7 12.8 9.3 18.8zM450 230.9l-1.2 3.1c-2.3 16.2-7.2 32.3-14.8 47.2 8.3-15.7 13.7-32.9 16-50.3M265.7 2.5c5.7-2.1 14.1-1.2 20.2-2.5-7.9.7-15.8 1.1-23.6 2.1zM64.3 109.6c1.3 12.2-9.2 17 2.3 8.9 6.2-13.9-2.4-3.8-2.3-8.9m-13.6 56.7c2.7-8.2 3.1-13.1 4.2-17.8-7.4 9.4-3.4 11.4-4.2 17.8" class="st0"/></svg>
|
||||
|
After Width: | Height: | Size: 3.0 KiB |
1
assets/icons/docker.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="-0.07 71.3 511.97 369.5"><path d="M501.4 212.3c-11.5-8-38-11-58.6-7-2.4-20-13.5-37.5-32.7-53l-11-8-7.7 11.5c-9.6 15-14.4 36-13 56 .5 7 2.9 19.5 10.1 30.5-6.7 4-20.7 9-38.9 9H2.3l-1 4c-3.4 20-3.4 82.5 36 130.5 29.8 36.5 74 55 132.1 55 125.9 0 219.1-60.5 262.8-170 17.3.5 54.3 0 73-37.5.5-1 1.4-3 4.8-10.5l1.9-4zM280 71.3h-52.8v50H280zm0 60h-52.8v50H280zm-62.5 0h-52.8v50h52.8zm-62.4 0h-52.8v50h52.8zm-62.5 60H39.8v50h52.8zm62.5 0h-52.8v50h52.8zm62.4 0h-52.8v50h52.8zm62.5 0h-52.8v50H280zm62.4 0h-52.8v50h52.8z" style="fill:#2396ed"/></svg>
|
||||
|
After Width: | Height: | Size: 608 B |
1
assets/icons/esxi.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400"><path fill="#78be20" d="M384 68.078v106.413h16V68.078A68.156 68.156 0 0 0 331.922 0H224.044l-16 16h123.878A52.137 52.137 0 0 1 384 68.078"/><path fill="#00c1d5" d="M172.738 384H68.078A52.14 52.14 0 0 1 16 331.922V68.078A52.14 52.14 0 0 1 68.078 16h104.565l16-16H68.078A68.156 68.156 0 0 0 0 68.078v263.844A68.156 68.156 0 0 0 68.078 400h104.66Z"/><path fill="#0091da" d="M62.844 78.27V255.8a15.46 15.46 0 0 0 15.44 15.442h22.332v16H78.284a31.476 31.476 0 0 1-31.44-31.442V78.27a31.476 31.476 0 0 1 31.44-31.44H251.5a31.476 31.476 0 0 1 31.44 31.44v18.054h-16V78.27a15.457 15.457 0 0 0-15.44-15.44H78.284a15.457 15.457 0 0 0-15.44 15.44m84.705 274.374h25.189v-16h-25.189a15.457 15.457 0 0 1-15.441-15.44V143.673a15.46 15.46 0 0 1 15.441-15.441H320.76a15.457 15.457 0 0 1 15.44 15.441v30.818h16v-30.818a31.476 31.476 0 0 0-31.44-31.441H147.549a31.476 31.476 0 0 0-31.441 31.441V321.2a31.476 31.476 0 0 0 31.441 31.444"/><path fill="#1d428a" d="M247.125 294.54a45.974 45.974 0 1 1 45.975-45.974 46.026 46.026 0 0 1-45.975 45.974m0-75.948a29.974 29.974 0 1 0 29.975 29.974 30.01 30.01 0 0 0-29.975-29.974m106.082 182.031a45.974 45.974 0 1 1 45.975-45.975 46.026 46.026 0 0 1-45.975 45.975m0-75.948a29.974 29.974 0 1 0 29.975 29.973 30.01 30.01 0 0 0-29.975-29.973m45.92-32.033h-88.15v-88.151h88.15Zm-72.15-16h56.15v-56.151h-56.15ZM290.889 398.88h-88.151v-88.151h88.151Zm-72.151-16h56.151v-56.151h-56.151Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
1
assets/icons/exchange.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="4 8 55 48"><path fill="#28a8ea" d="M55.51 8H43.303a3.5 3.5 0 0 0-2.468 1.022L12.022 37.835A3.5 3.5 0 0 0 11 40.303V52.51A3.49 3.49 0 0 0 14.49 56h12.207a3.5 3.5 0 0 0 2.468-1.022l28.813-28.813A3.5 3.5 0 0 0 59 23.697V11.49A3.49 3.49 0 0 0 55.51 8"/><path fill="#0078d4" d="M55.51 56H43.303a3.5 3.5 0 0 1-2.468-1.022L35 49.143V38.24A6.24 6.24 0 0 1 41.24 32h10.903l5.835 5.835A3.5 3.5 0 0 1 59 40.303V52.51A3.49 3.49 0 0 1 55.51 56"/><path fill="#50d9ff" d="M14.49 8h12.207a3.5 3.5 0 0 1 2.468 1.022L35 14.857V25.76A6.24 6.24 0 0 1 28.76 32H17.857l-5.835-5.835A3.5 3.5 0 0 1 11 23.697V11.49A3.49 3.49 0 0 1 14.49 8"/><path d="M33 20.33v26.34a1.7 1.7 0 0 1-.04.4A2.314 2.314 0 0 1 30.67 49H11V18h19.67A2.326 2.326 0 0 1 33 20.33" opacity=".2"/><path d="M34 20.33v24.34A3.36 3.36 0 0 1 30.67 48H11V17h19.67A3.34 3.34 0 0 1 34 20.33" opacity=".1"/><path d="M33 20.33v24.34A2.326 2.326 0 0 1 30.67 47H11V18h19.67A2.326 2.326 0 0 1 33 20.33" opacity=".2"/><path d="M32 20.33v24.34A2.326 2.326 0 0 1 29.67 47H11V18h18.67A2.326 2.326 0 0 1 32 20.33" opacity=".1"/><rect width="28" height="28" x="4" y="18" fill="#0078d4" rx="2.333"/><path fill="#fff" d="M22.585 26.881h-6.547v3.829h6.145v2.454h-6.145v3.976h6.896v2.443h-9.868V24.417h9.52Z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
31
assets/icons/opnsense.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg version="1.1" viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<image width="200" height="200" display="none" image-rendering="optimizeSpeed" preserveAspectRatio="none" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAYAAACtWK6eAAAFeklEQVR4nOzdP48VVRjA4VlCQgIm
|
||||
2EChhSQWGgpzGxtiAvoBoLQE1I5CKNhSxUr3FrLFlrhraedSWemSGBobYiIGE+NujERZGwtJMDHX
|
||||
XMJNXCCv98/MnHNmnucDjKfwl/Mezuzc/aPRqCrF7tebx29/8M73qdfRN4cHJ64OVjcvpV5HCvtS
|
||||
L4D8/Xnr5sXb77+9knodKQiEqexuXb98690zn6ReR9sEwtT6uJMIhJn0bScRCDMb7yR9iUQgzKUv
|
||||
45ZAmFsfxi2BsJCuj1sCYWFdHrcEQi26Om4JhNp0cScRCLXq2k4iEGrXpYO7QGhEV8YtgdCYLoxb
|
||||
AqFR451ke33lbOp1zEsgNG5nY7hWaiQCoQ2HdjaGGyWOWwKhNSUe3AVCq0o7uAuE1pV0TyIQkihl
|
||||
3BIIyZQwbgmEpHK/JxEIyeV8TyIQcpDtPYlAyEaOB3eBkJXcDu4CITs53ZMIhCzlMm4JhGzlMG4J
|
||||
hKyl3kkEQvZS7iQCoQipDu4CoRgpxi2BUJS2xy2BUJw2X3AUCEXa2RiutTFuCYRSHWpj3BIIRWv6
|
||||
4C4QitfkTiIQOqGpe5Kl37/6YvDH1vUzdT+4CQ/u3T144Ohzz6ReB/P5a/vOy/e377zR5H/jyKnT
|
||||
w+NXri3X9bylnz/9+NzOxnC9rgc2bPfkjd2jqRfBfLbXV1r5f+3w4MTVwermpTqeZcSic+q8JxEI
|
||||
nVTXhyAEQlfV8iEIgdBpi96TCITOW+SeRCD0wrz3JAKhN+YZtwRCr8w6bgmE3pllJxEIvTTtTiIQ
|
||||
emuag7tA6LX/G7cEQu9F45ZAIBi3BAKPPG3cEgj8x+PjlkDgMY92ko8qgcDT7W5df6sSCMQEAgGB
|
||||
QEAgEBAIBAQCAYFAQCAQEAgEBAIBgUBAIBAQCAQEAgGBQEAgEBAIBAQCAYFAQCAQEAgEBAIBgUBA
|
||||
IBAQCAQEAgGBQEAgEBAIBAQCAYFAQCAQEAgEBAIBgUBAIBAQCAQEAgGBQEAgEBAIBAQCAYFAQCAQ
|
||||
EAgEBAIBgUBAIBAQCAQEAgGBQEAgEBAIBAQCAYFAQCAQEAgEBAIBgUBAIBAQCAQEAgGBQEAgEBAI
|
||||
BAQCAYFAQCAQEAgEBAIBgUBAIBAQCATGgfyWehGQq33Hzi9/efDYSxupFwI5ejhivTL8/IJI4EkP
|
||||
Azlw9Pn7r372zXmRwF57Dul2EthrTyCTneTw4MTFdEuCfDz1n3kHq5urdhII7kGMWxAEYtyCKW7S
|
||||
jVv02VSvmhi36KupAnFPQl/N9LKinYS+mSkQB3f6Zq7X3R3c6Yu5/x7EuEUfzB2IcYs+WPgvCsfj
|
||||
1pFTp5frWQ7kpZY/uT1+5drQTkIXLY1Go9oe9u3Z19bvb985V9sDn/RPVVW/NPh8mvXDC+cur6Re
|
||||
xJT+PnZ++WatgTy49+vB7y6/udZwJJRr6+SN3ddTL2IWtX7VxMGdrmnksz/uSeiKxr6L5Z6ELmgs
|
||||
EOMWXdD4lxXdk1CyVj496p6EUrX2bV4Hd0rU6serHdwpTauBOLhTmiQ/f2DcohTJfh/EuEUJkgXi
|
||||
QxCUIPkvTNlJyFnyQBzcyVnyQCYc3MlRNoFUxi0ylFUgxi1yk1UgE15wJBdZBjL24oUP14xbpJZt
|
||||
IO5JyEG2gUw4uJNS9oE4uJNS9oFMuCchhWICqYxbJFBUIMYt2lZUIBPuSWhLkYFUPgRBS4oNpHJw
|
||||
pwVFB1I5uNOw4gNxcKdJxQcyYdyiCZ0JpDJu0YBOBeIFR+rWqUAm7CTUpZOBOLhTl04GMuHgzqI6
|
||||
HUhl3GJBnQ/EuMUiOh/IhHGLefQmkMq4xRx6FYh7Ema1P/UCUhjvJD+tvXe3qqpnU6+lZ35MvYBZ
|
||||
LY1Go9RrgGz9GwAA//8/Z0LNgEyjNwAAAABJRU5ErkJggg==
|
||||
"/>
|
||||
<path d="m44 0v44h112v112h44v-92.93l-63.1-63.07zm112 156h-112v-112h-44v92l64 64h92z" fill="#c03e14"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
1
assets/icons/proxmox.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 34 512 444"><path d="M137.9 34.1c-10.5 0-19.7 1.9-28.5 5.7-8.6 3.8-16.2 8.9-22.9 15.6l170 186.4L426.1 55.3c-6.7-6.7-14.3-11.8-23.4-15.6-8.3-3.8-18-5.7-28-5.7-10.5 0-20.5 2.2-29.4 6.2-9.2 4-16.7 10-23.7 17l-65.2 72.2-66-72.2c-6.7-7-14.3-12.9-23.7-17-8.3-4-18.3-6.1-28.8-6.1M256.4 270l-170 186.7c6.7 6.5 14.3 11.8 22.9 15.6 8.9 3.8 18.1 5.7 28 5.7 11 0 20.5-2.4 29.4-6.2 9.4-4.3 17.5-10 24.2-17l65.5-72.2 65.4 72.2c6.7 7 14.3 12.7 23.4 17 8.9 3.8 18.6 6.2 29.4 6.2 10 0 19.7-1.9 28-5.7 9.2-3.8 16.7-9.2 23.4-15.6z" style="fill-rule:evenodd;clip-rule:evenodd"/><path d="M56 90.1c-10.8.3-21.3 2.4-30.7 6.5-9.7 4-18 9.7-25.3 16.7L129.8 256 0 398.5c7.3 7.3 15.6 12.9 25.3 17.2 9.4 4.3 19.9 6.2 30.7 6.7 11.6-.5 22.4-2.4 32.3-7.3q15-6.9 25.8-18.6l128-140.5-127.9-140.3c-7.8-7.5-16.2-13.7-26.1-18.6-10-4.6-20.5-6.7-32.1-7m399.7 0c-11.6.3-21.8 2.4-31.8 7-10 4.8-18.6 11-26.1 18.6L270.4 256l127.4 140.6q11.25 11.7 26.1 18.6c10 4.8 20.2 6.7 31.8 7.3 11.6-.5 21.5-2.4 31-6.7 10.2-4.3 18-10 25.3-17.2L382.5 256 512 113.3c-7.3-7-15.1-12.7-25.3-16.7-9.4-4.1-19.4-6.2-31-6.5" style="fill-rule:evenodd;clip-rule:evenodd;fill:#e57000"/></svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
BIN
assets/icons/windows-11.png
Normal file
|
After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 81 KiB |
BIN
current_view.png
|
Before Width: | Height: | Size: 76 KiB |
BIN
customer_card.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
155
data_loader.py
@@ -2,7 +2,7 @@ import yaml
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Any
|
||||
from models import (
|
||||
Customer, CustomerService, Location, Host, Service,
|
||||
Customer, CustomerService, Location, Host, Service, NetworkSegment, HostIP, PortForwarding,
|
||||
ServiceType, HostType, VPNType
|
||||
)
|
||||
|
||||
@@ -73,11 +73,42 @@ def parse_host(host_data: Dict[str, Any]) -> Host:
|
||||
)
|
||||
services.append(service)
|
||||
|
||||
# Parse IP addresses - handle both new HostIP format and legacy formats
|
||||
ip_addresses = []
|
||||
if 'ip_addresses' in host_data:
|
||||
for ip_data in host_data['ip_addresses']:
|
||||
if isinstance(ip_data, dict):
|
||||
# New HostIP format: {ip_address: "192.168.1.10", network_segment: "LAN", is_primary: true}
|
||||
host_ip = HostIP(
|
||||
ip_address=ip_data['ip_address'],
|
||||
network_segment=ip_data.get(
|
||||
'network_segment', 'LAN'), # Default segment
|
||||
is_primary=ip_data.get('is_primary', False)
|
||||
)
|
||||
ip_addresses.append(host_ip)
|
||||
else:
|
||||
# Legacy format: simple string list
|
||||
host_ip = HostIP(
|
||||
ip_address=ip_data,
|
||||
network_segment='LAN', # Default segment for legacy format
|
||||
is_primary=len(ip_addresses) == 0 # First IP is primary
|
||||
)
|
||||
ip_addresses.append(host_ip)
|
||||
elif 'ip_address' in host_data:
|
||||
# Very old format: single IP string
|
||||
host_ip = HostIP(
|
||||
ip_address=host_data['ip_address'],
|
||||
network_segment='LAN',
|
||||
is_primary=True
|
||||
)
|
||||
ip_addresses.append(host_ip)
|
||||
|
||||
# Create host
|
||||
host = Host(
|
||||
name=host_data['name'],
|
||||
ip_address=host_data['ip_address'],
|
||||
ip_addresses=ip_addresses,
|
||||
host_type=parse_host_type(host_data['host_type']),
|
||||
icon=host_data.get('icon'), # Custom icon name
|
||||
description=host_data.get('description', ''),
|
||||
services=services
|
||||
)
|
||||
@@ -93,6 +124,34 @@ def parse_host(host_data: Dict[str, Any]) -> Host:
|
||||
|
||||
def parse_location(location_data: Dict[str, Any]) -> Location:
|
||||
"""Parse a location from YAML data."""
|
||||
# Parse network segments
|
||||
network_segments = []
|
||||
if 'network_segments' in location_data:
|
||||
for segment_data in location_data['network_segments']:
|
||||
segment = NetworkSegment(
|
||||
name=segment_data['name'],
|
||||
cidr=segment_data['cidr'],
|
||||
vlan_id=segment_data.get('vlan_id'),
|
||||
zone=segment_data.get('zone', 'general'),
|
||||
gateway=segment_data.get('gateway'),
|
||||
description=segment_data.get('description', '')
|
||||
)
|
||||
network_segments.append(segment)
|
||||
|
||||
# Parse port forwardings
|
||||
port_forwardings = []
|
||||
if 'port_forwardings' in location_data:
|
||||
for pf_data in location_data['port_forwardings']:
|
||||
port_forward = PortForwarding(
|
||||
external_port=pf_data['external_port'],
|
||||
internal_ip=pf_data['internal_ip'],
|
||||
internal_port=pf_data['internal_port'],
|
||||
protocol=pf_data.get('protocol', 'tcp'),
|
||||
description=pf_data.get('description', ''),
|
||||
enabled=pf_data.get('enabled', True)
|
||||
)
|
||||
port_forwardings.append(port_forward)
|
||||
|
||||
# Parse hosts
|
||||
hosts = []
|
||||
if 'hosts' in location_data:
|
||||
@@ -108,6 +167,10 @@ def parse_location(location_data: Dict[str, Any]) -> Location:
|
||||
active=False, # Runtime state - always starts inactive
|
||||
vpn_config=location_data.get('vpn_config', ''),
|
||||
hosts=hosts,
|
||||
network_segments=network_segments,
|
||||
networks=location_data.get('networks', []), # Legacy support
|
||||
external_addresses=location_data.get('external_addresses', []),
|
||||
port_forwardings=port_forwardings,
|
||||
vpn_credentials=location_data.get('vpn_credentials'),
|
||||
nmcli_connection_name=location_data.get('nmcli_connection_name'),
|
||||
auto_import=location_data.get('auto_import', True)
|
||||
@@ -204,20 +267,64 @@ def save_customer(customer: Customer, filename: str = None) -> None:
|
||||
|
||||
# Convert locations
|
||||
for location in customer.locations:
|
||||
# Convert network segments
|
||||
network_segments = []
|
||||
for segment in location.network_segments:
|
||||
segment_data = {
|
||||
'name': segment.name,
|
||||
'cidr': segment.cidr,
|
||||
'zone': segment.zone,
|
||||
'description': segment.description
|
||||
}
|
||||
if segment.vlan_id is not None:
|
||||
segment_data['vlan_id'] = segment.vlan_id
|
||||
if segment.gateway is not None:
|
||||
segment_data['gateway'] = segment.gateway
|
||||
network_segments.append(segment_data)
|
||||
|
||||
# Convert port forwardings
|
||||
port_forwardings = []
|
||||
for pf in location.port_forwardings:
|
||||
pf_data = {
|
||||
'external_port': pf.external_port,
|
||||
'internal_ip': pf.internal_ip,
|
||||
'internal_port': pf.internal_port,
|
||||
'protocol': pf.protocol,
|
||||
'enabled': pf.enabled
|
||||
}
|
||||
if pf.description:
|
||||
pf_data['description'] = pf.description
|
||||
port_forwardings.append(pf_data)
|
||||
|
||||
location_data = {
|
||||
'name': location.name,
|
||||
'vpn_type': location.vpn_type.value,
|
||||
'vpn_config': location.vpn_config,
|
||||
'active': location.active,
|
||||
'connected': location.connected,
|
||||
'network_segments': network_segments,
|
||||
'external_addresses': location.external_addresses,
|
||||
'port_forwardings': port_forwardings,
|
||||
'hosts': []
|
||||
}
|
||||
|
||||
# Add legacy networks if they exist
|
||||
if location.networks:
|
||||
location_data['networks'] = location.networks
|
||||
|
||||
# Convert hosts
|
||||
def convert_host(host):
|
||||
# Convert HostIP objects back to dictionaries
|
||||
ip_addresses = []
|
||||
for host_ip in host.ip_addresses:
|
||||
ip_dict = {
|
||||
'ip_address': host_ip.ip_address,
|
||||
'network_segment': host_ip.network_segment,
|
||||
'is_primary': host_ip.is_primary
|
||||
}
|
||||
ip_addresses.append(ip_dict)
|
||||
|
||||
host_data = {
|
||||
'name': host.name,
|
||||
'ip_address': host.ip_address,
|
||||
'ip_addresses': ip_addresses,
|
||||
'host_type': host.host_type.value,
|
||||
'description': host.description,
|
||||
'services': [
|
||||
@@ -230,6 +337,10 @@ def save_customer(customer: Customer, filename: str = None) -> None:
|
||||
]
|
||||
}
|
||||
|
||||
# Add icon if specified
|
||||
if host.icon:
|
||||
host_data['icon'] = host.icon
|
||||
|
||||
if host.sub_hosts:
|
||||
host_data['sub_hosts'] = [convert_host(
|
||||
subhost) for subhost in host.sub_hosts]
|
||||
@@ -267,12 +378,22 @@ def get_demo_customers() -> List[Customer]:
|
||||
vpn_type=VPNType.OPENVPN,
|
||||
connected=False,
|
||||
active=True,
|
||||
vpn_config="/etc/openvpn/demo.ovpn"
|
||||
vpn_config="demo.ovpn" # File in ~/.vpntray/vpn/
|
||||
)
|
||||
|
||||
# Create a demo network segment
|
||||
demo_segment = NetworkSegment(
|
||||
name="LAN",
|
||||
cidr="10.0.0.0/24",
|
||||
gateway="10.0.0.1",
|
||||
zone="production",
|
||||
description="Demo network"
|
||||
)
|
||||
|
||||
demo_host = Host(
|
||||
name="DEMO-01",
|
||||
ip_address="10.0.0.1",
|
||||
ip_addresses=[HostIP(ip_address="10.0.0.1",
|
||||
network_segment="LAN", is_primary=True)],
|
||||
host_type=HostType.LINUX,
|
||||
description="Demo server",
|
||||
services=[
|
||||
@@ -282,6 +403,7 @@ def get_demo_customers() -> List[Customer]:
|
||||
)
|
||||
|
||||
demo_location.hosts = [demo_host]
|
||||
demo_location.network_segments = [demo_segment]
|
||||
demo_customer.locations = [demo_location]
|
||||
|
||||
return [demo_customer]
|
||||
@@ -317,13 +439,24 @@ services:
|
||||
locations:
|
||||
- name: Main Office
|
||||
vpn_type: WireGuard
|
||||
vpn_config: /etc/wireguard/simple.conf
|
||||
active: false
|
||||
connected: false
|
||||
vpn_config: simple.conf # File in ~/.vpntray/vpn/
|
||||
|
||||
network_segments:
|
||||
- name: LAN
|
||||
cidr: 192.168.1.0/24
|
||||
gateway: 192.168.1.1
|
||||
zone: production
|
||||
description: Main office network
|
||||
|
||||
external_addresses:
|
||||
- simple.vpn.example.com
|
||||
|
||||
hosts:
|
||||
- name: SERVER-01
|
||||
ip_address: 192.168.1.10
|
||||
ip_addresses:
|
||||
- ip_address: 192.168.1.10
|
||||
network_segment: LAN
|
||||
is_primary: true
|
||||
host_type: Linux
|
||||
description: Main server
|
||||
services:
|
||||
|
||||
@@ -22,7 +22,72 @@ services:
|
||||
locations:
|
||||
- name: Main Office
|
||||
vpn_type: OpenVPN
|
||||
vpn_config: /etc/openvpn/techcorp-main.ovpn
|
||||
vpn_config: techcorp-main.ovpn # File in ~/.vpntray/vpn/
|
||||
|
||||
# External connection endpoints (can have multiple for redundancy)
|
||||
external_addresses:
|
||||
- vpn.techcorp.com # Primary VPN endpoint
|
||||
- vpn2.techcorp.com # Backup endpoint
|
||||
- 203.0.113.10 # Direct IP fallback
|
||||
|
||||
# Port forwarding rules for external access
|
||||
port_forwardings:
|
||||
- external_port: 8006
|
||||
internal_ip: 192.168.1.10
|
||||
internal_port: 8006
|
||||
protocol: tcp
|
||||
description: Proxmox web interface
|
||||
enabled: true
|
||||
|
||||
- external_port: 3389
|
||||
internal_ip: 192.168.1.20
|
||||
internal_port: 3389
|
||||
protocol: tcp
|
||||
description: Domain Controller RDP
|
||||
enabled: true
|
||||
|
||||
- external_port: 9000
|
||||
internal_ip: 192.168.1.21
|
||||
internal_port: 9000
|
||||
protocol: tcp
|
||||
description: File server web panel
|
||||
enabled: true
|
||||
|
||||
- external_port: 5050
|
||||
internal_ip: 192.168.1.22
|
||||
internal_port: 5050
|
||||
protocol: tcp
|
||||
description: pgAdmin database interface
|
||||
enabled: true
|
||||
|
||||
- external_port: 443
|
||||
internal_ip: 192.168.1.1
|
||||
internal_port: 443
|
||||
protocol: tcp
|
||||
description: Firewall web interface
|
||||
enabled: true
|
||||
|
||||
# Network segments with rich metadata
|
||||
network_segments:
|
||||
- name: LAN
|
||||
cidr: 192.168.1.0/24
|
||||
gateway: 192.168.1.1
|
||||
zone: production
|
||||
description: Main office LAN
|
||||
|
||||
- name: Management
|
||||
cidr: 10.0.1.0/24
|
||||
vlan_id: 100
|
||||
gateway: 10.0.1.1
|
||||
zone: management
|
||||
description: Out-of-band management network
|
||||
|
||||
- name: Services
|
||||
cidr: 172.16.1.0/24
|
||||
vlan_id: 200
|
||||
gateway: 172.16.1.1
|
||||
zone: production
|
||||
description: Internal services network
|
||||
|
||||
# VPN credentials - three options:
|
||||
# Option 1: Dictionary with username/password
|
||||
@@ -39,8 +104,12 @@ locations:
|
||||
# Hosts at this location
|
||||
hosts:
|
||||
- name: PVE-01
|
||||
ip_address: 192.168.1.10
|
||||
ip_addresses:
|
||||
- ip_address: 192.168.1.10
|
||||
network_segment: LAN
|
||||
is_primary: true
|
||||
host_type: Proxmox
|
||||
icon: proxmox # Custom icon: assets/icons/proxmox.svg
|
||||
description: Main virtualization server
|
||||
services:
|
||||
- name: Web Interface
|
||||
@@ -53,7 +122,10 @@ locations:
|
||||
# VMs running on this host
|
||||
sub_hosts:
|
||||
- name: DC-01
|
||||
ip_address: 192.168.1.20
|
||||
ip_addresses:
|
||||
- ip_address: 192.168.1.20
|
||||
network_segment: LAN
|
||||
is_primary: true
|
||||
host_type: Windows Server
|
||||
description: Domain Controller
|
||||
services:
|
||||
@@ -65,8 +137,12 @@ locations:
|
||||
port: 8080
|
||||
|
||||
- name: FILE-01
|
||||
ip_address: 192.168.1.21
|
||||
ip_addresses:
|
||||
- ip_address: 192.168.1.21
|
||||
network_segment: LAN
|
||||
is_primary: true
|
||||
host_type: Linux
|
||||
icon: ubuntu # Custom icon: assets/icons/ubuntu.svg
|
||||
description: File Server (Samba)
|
||||
services:
|
||||
- name: SSH
|
||||
@@ -80,9 +156,15 @@ locations:
|
||||
port: 9000
|
||||
|
||||
- name: DB-01
|
||||
ip_address: 192.168.1.22
|
||||
ip_addresses:
|
||||
- ip_address: 192.168.1.22
|
||||
network_segment: LAN
|
||||
is_primary: true
|
||||
- ip_address: 172.16.1.22
|
||||
network_segment: Services
|
||||
is_primary: false
|
||||
host_type: Linux
|
||||
description: PostgreSQL Database
|
||||
description: PostgreSQL Database (dual-homed)
|
||||
services:
|
||||
- name: SSH
|
||||
service_type: SSH
|
||||
@@ -95,9 +177,19 @@ locations:
|
||||
port: 5050
|
||||
|
||||
- name: FW-01
|
||||
ip_address: 192.168.1.1
|
||||
ip_addresses:
|
||||
- ip_address: 192.168.1.1
|
||||
network_segment: LAN
|
||||
is_primary: true
|
||||
- ip_address: 10.0.1.1
|
||||
network_segment: Management
|
||||
is_primary: false
|
||||
- ip_address: 172.16.1.1
|
||||
network_segment: Services
|
||||
is_primary: false
|
||||
host_type: Router
|
||||
description: pfSense Firewall/Router
|
||||
icon: pfsense # Custom icon: assets/icons/pfsense.svg
|
||||
description: pfSense Firewall/Router (multi-interface)
|
||||
services:
|
||||
- name: Web Interface
|
||||
service_type: Web GUI
|
||||
@@ -107,9 +199,15 @@ locations:
|
||||
port: 22
|
||||
|
||||
- name: SW-01
|
||||
ip_address: 192.168.1.2
|
||||
ip_addresses:
|
||||
- ip_address: 192.168.1.2
|
||||
network_segment: LAN
|
||||
is_primary: true
|
||||
- ip_address: 10.0.1.2
|
||||
network_segment: Management
|
||||
is_primary: false
|
||||
host_type: Switch
|
||||
description: Managed Switch
|
||||
description: Managed Switch (dual-homed)
|
||||
services:
|
||||
- name: Web Interface
|
||||
service_type: Web GUI
|
||||
@@ -120,16 +218,57 @@ locations:
|
||||
|
||||
- name: Branch Office
|
||||
vpn_type: WireGuard
|
||||
vpn_config: /etc/wireguard/techcorp-branch.conf
|
||||
vpn_config: techcorp-branch.conf # File in ~/.vpntray/vpn/
|
||||
|
||||
# External connection endpoints
|
||||
external_addresses:
|
||||
- 198.51.100.50 # Branch office static IP
|
||||
- branch.techcorp.com # Dynamic DNS endpoint
|
||||
|
||||
# Port forwarding rules
|
||||
port_forwardings:
|
||||
- external_port: 8080
|
||||
internal_ip: 10.10.1.10
|
||||
internal_port: 8080
|
||||
protocol: tcp
|
||||
description: Branch web services
|
||||
enabled: true
|
||||
|
||||
- external_port: 22
|
||||
internal_ip: 10.10.1.10
|
||||
internal_port: 22
|
||||
protocol: tcp
|
||||
description: SSH access to branch server
|
||||
enabled: false # Disabled for security
|
||||
|
||||
# Network segments
|
||||
network_segments:
|
||||
- name: Branch_LAN
|
||||
cidr: 10.10.1.0/24
|
||||
gateway: 10.10.1.1
|
||||
zone: production
|
||||
description: Branch office network
|
||||
|
||||
- name: Local_Services
|
||||
cidr: 192.168.100.0/24
|
||||
gateway: 192.168.100.1
|
||||
zone: general
|
||||
description: Local branch services network
|
||||
|
||||
# No credentials needed for WireGuard (uses keys in config file)
|
||||
vpn_credentials: null
|
||||
|
||||
hosts:
|
||||
- name: BRANCH-01
|
||||
ip_address: 10.10.1.10
|
||||
ip_addresses:
|
||||
- ip_address: 10.10.1.10
|
||||
network_segment: Branch_LAN
|
||||
is_primary: true
|
||||
- ip_address: 192.168.100.1
|
||||
network_segment: Local_Services
|
||||
is_primary: false
|
||||
host_type: Linux
|
||||
description: Branch office server
|
||||
description: Branch office server (dual-homed)
|
||||
services:
|
||||
- name: SSH
|
||||
service_type: SSH
|
||||
|
||||
@@ -12,12 +12,12 @@ 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()
|
||||
@@ -32,11 +32,11 @@ def main():
|
||||
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()
|
||||
main()
|
||||
|
||||
490
main.py
@@ -1,22 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
from views import ActiveView, InactiveView
|
||||
from views import ActiveView, InactiveView, LogView
|
||||
from data_loader import load_customers
|
||||
from models import Customer
|
||||
from PIL import Image, ImageDraw
|
||||
import pystray
|
||||
import threading
|
||||
# from services import VPNManager, VPNStatus, VPNConnectionError # Temporarily disabled due to syntax errors
|
||||
from services import VPNManager, VPNStatus, VPNConnectionError
|
||||
import sys
|
||||
from gi.repository import Gtk, Gdk, GLib
|
||||
import logging
|
||||
from gi.repository import Gtk, Gdk, GLib, Gio
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
|
||||
class VPNManagerWindow:
|
||||
vpn_manager: VPNManager
|
||||
|
||||
def __init__(self):
|
||||
self.customers = load_customers()
|
||||
self.filtered_customers = self.customers.copy()
|
||||
self.current_location = None # Track user's current location
|
||||
|
||||
# VPN manager will be initialized after UI setup
|
||||
self.vpn_manager = None
|
||||
|
||||
# Create main window
|
||||
self.window = Gtk.Window()
|
||||
self.window.set_title("VPN Manager")
|
||||
@@ -24,30 +29,18 @@ class VPNManagerWindow:
|
||||
self.window.connect("delete-event", self.quit_app_from_close)
|
||||
self.window.connect("window-state-event", self.on_window_state_event)
|
||||
|
||||
# Set up minimal CSS for GNOME-style cards
|
||||
# Set up minimal CSS for GNOME-style cards
|
||||
self.setup_css()
|
||||
|
||||
# Create UI
|
||||
self.setup_ui()
|
||||
self.setup_system_tray()
|
||||
|
||||
# Start hidden
|
||||
self.window.hide()
|
||||
self.vpn_manager = VPNManager()
|
||||
|
||||
def setup_css(self):
|
||||
"""Minimal CSS for GNOME-style cards"""
|
||||
css_provider = Gtk.CssProvider()
|
||||
css = """
|
||||
.card {
|
||||
background: @theme_base_color;
|
||||
border-radius: 8px;
|
||||
border: 1px solid @borders;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
padding: 16px;
|
||||
margin: 6px;
|
||||
}
|
||||
"""
|
||||
css_provider.load_from_data(css.encode())
|
||||
css_provider.load_from_file(Gio.File.new_for_path('style.css'))
|
||||
# css_provider.load_from_data(css.encode())
|
||||
|
||||
# Apply CSS to default screen
|
||||
screen = Gdk.Screen.get_default()
|
||||
@@ -72,12 +65,9 @@ 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)
|
||||
# Current location display - enhanced info box
|
||||
self.location_info_box = self._create_location_info_box()
|
||||
main_vbox.pack_start(self.location_info_box, False, False, 0)
|
||||
|
||||
# Search bar with SearchEntry
|
||||
self.search_entry = Gtk.SearchEntry()
|
||||
@@ -91,62 +81,65 @@ class VPNManagerWindow:
|
||||
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)
|
||||
|
||||
|
||||
# Get callbacks for views
|
||||
callbacks = self.get_callbacks()
|
||||
|
||||
|
||||
# Create active view (shown by default)
|
||||
self.active_view = ActiveView(callbacks)
|
||||
self.view_stack.add_named(self.active_view.widget, "active")
|
||||
|
||||
|
||||
# Create inactive view (shown when searching)
|
||||
self.inactive_view = InactiveView(callbacks)
|
||||
self.view_stack.add_named(self.inactive_view.widget, "inactive")
|
||||
|
||||
# Create log section at bottom (collapsible)
|
||||
self._create_log_section(main_vbox)
|
||||
|
||||
# Initialize VPN manager (temporarily disabled due to syntax errors)
|
||||
# TODO: Fix VPN manager syntax and re-enable
|
||||
self.vpn_manager = None
|
||||
self.log_view.log_info(
|
||||
"VPN manager temporarily disabled for debugging")
|
||||
self.log_view.log_info("Using mock mode for VPN operations")
|
||||
|
||||
# Render initial data
|
||||
self.render_customers()
|
||||
|
||||
def setup_system_tray(self):
|
||||
# Create a simple icon for the system tray
|
||||
def create_icon():
|
||||
# Create a simple network icon
|
||||
width = height = 64
|
||||
image = Image.new('RGBA', (width, height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
# Update VPN status from actual connections
|
||||
self.update_vpn_status()
|
||||
|
||||
# Draw a simple network/VPN icon
|
||||
# Outer circle
|
||||
draw.ellipse([8, 8, 56, 56], outline=(50, 150, 50), width=4)
|
||||
# Inner dot
|
||||
draw.ellipse([26, 26, 38, 38], fill=(50, 150, 50))
|
||||
# Connection lines
|
||||
draw.line([32, 16, 32, 24], fill=(50, 150, 50), width=3)
|
||||
draw.line([32, 40, 32, 48], fill=(50, 150, 50), width=3)
|
||||
draw.line([16, 32, 24, 32], fill=(50, 150, 50), width=3)
|
||||
draw.line([40, 32, 48, 32], fill=(50, 150, 50), width=3)
|
||||
def _setup_logging(self):
|
||||
"""Set up logging to route VPN manager logs to LogView."""
|
||||
# Create a custom handler that forwards to our LogView
|
||||
class LogViewHandler(logging.Handler):
|
||||
def __init__(self, log_view):
|
||||
super().__init__()
|
||||
self.log_view = log_view
|
||||
|
||||
return image
|
||||
def emit(self, record):
|
||||
try:
|
||||
msg = self.format(record)
|
||||
if record.levelno >= logging.ERROR:
|
||||
self.log_view.log_error(msg)
|
||||
elif record.levelno >= logging.WARNING:
|
||||
self.log_view.log_warning(msg)
|
||||
elif record.levelno >= logging.INFO:
|
||||
self.log_view.log_info(msg)
|
||||
else: # DEBUG
|
||||
self.log_view.log_debug(msg)
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
# Simple approach: Create tray icon with direct action and minimal menu
|
||||
self.tray_icon = pystray.Icon(
|
||||
"VPN Manager",
|
||||
create_icon(),
|
||||
"VPN Manager - Double-click to open"
|
||||
)
|
||||
# Set up handler for VPN manager logs
|
||||
handler = LogViewHandler(self.log_view)
|
||||
handler.setFormatter(logging.Formatter('%(message)s'))
|
||||
|
||||
# Set direct click action
|
||||
self.tray_icon.default_action = self.show_window_from_tray
|
||||
|
||||
# Also provide a right-click menu
|
||||
menu = pystray.Menu(
|
||||
pystray.MenuItem("Open VPN Manager",
|
||||
self.show_window_from_tray, default=True),
|
||||
pystray.MenuItem("Quit", self.quit_app)
|
||||
)
|
||||
self.tray_icon.menu = menu
|
||||
|
||||
# Start tray icon in separate thread
|
||||
threading.Thread(target=self.tray_icon.run, daemon=True).start()
|
||||
# Add handler to VPN manager logger
|
||||
vpn_logger = logging.getLogger('services.vpn_manager')
|
||||
vpn_logger.addHandler(handler)
|
||||
vpn_logger.setLevel(logging.DEBUG)
|
||||
vpn_logger.propagate = False # Don't send to root logger
|
||||
|
||||
def get_callbacks(self):
|
||||
"""Return callback functions for widget interactions"""
|
||||
@@ -204,10 +197,12 @@ class VPNManagerWindow:
|
||||
target_location = customer.get_location_by_name(location.name)
|
||||
if target_location:
|
||||
target_location.active = True
|
||||
self.log_view.log_info(
|
||||
f"Activated location: {customer.name} - {target_location.name}")
|
||||
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()
|
||||
@@ -219,35 +214,322 @@ class VPNManagerWindow:
|
||||
if target_location:
|
||||
target_location.active = False
|
||||
target_location.connected = False # Disconnect when deactivating
|
||||
self.log_view.log_info(
|
||||
f"Deactivated location: {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.current_location = (
|
||||
customer.name, target_location.name)
|
||||
self.log_view.log_info(
|
||||
f"Current location set to: {customer.name} - {target_location.name}")
|
||||
print(
|
||||
f"Current location set to: {customer.name} - {target_location.name}")
|
||||
self.update_current_location_display()
|
||||
break
|
||||
|
||||
|
||||
def _create_location_info_box(self):
|
||||
"""Create the enhanced current location info box."""
|
||||
frame = Gtk.Frame()
|
||||
frame.get_style_context().add_class("location-info")
|
||||
frame.set_shadow_type(Gtk.ShadowType.NONE)
|
||||
|
||||
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
frame.add(vbox)
|
||||
|
||||
# Title row with infrastructure toggle
|
||||
title_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
vbox.pack_start(title_box, False, False, 0)
|
||||
|
||||
title_label = Gtk.Label()
|
||||
title_label.set_markup("<b>📍 Current Location</b>")
|
||||
title_label.set_halign(Gtk.Align.START)
|
||||
title_box.pack_start(title_label, False, False, 0)
|
||||
|
||||
# Infrastructure toggle button (only shown when location is set)
|
||||
self.infrastructure_toggle = Gtk.Button()
|
||||
self.infrastructure_toggle.set_relief(Gtk.ReliefStyle.NONE)
|
||||
self.infrastructure_toggle.set_can_focus(False)
|
||||
self.infrastructure_toggle.set_label("▶")
|
||||
self.infrastructure_toggle.set_tooltip_text("Show/hide infrastructure")
|
||||
self.infrastructure_toggle.connect(
|
||||
"clicked", self._on_infrastructure_toggle)
|
||||
self.infrastructure_toggle.set_visible(False)
|
||||
title_box.pack_end(self.infrastructure_toggle, False, False, 0)
|
||||
|
||||
# Location details label
|
||||
self.location_details_label = Gtk.Label()
|
||||
self.location_details_label.set_markup("<i>Not set</i>")
|
||||
self.location_details_label.set_halign(Gtk.Align.START)
|
||||
vbox.pack_start(self.location_details_label, False, False, 0)
|
||||
|
||||
# Additional info row (hosts, services, etc.)
|
||||
self.location_extra_info = Gtk.Label()
|
||||
self.location_extra_info.set_halign(Gtk.Align.START)
|
||||
self.location_extra_info.set_visible(False)
|
||||
vbox.pack_start(self.location_extra_info, False, False, 0)
|
||||
|
||||
# Infrastructure section (collapsible)
|
||||
self.infrastructure_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||
self.infrastructure_box.set_margin_top(8)
|
||||
self.infrastructure_box.set_visible(False)
|
||||
vbox.pack_start(self.infrastructure_box, False, False, 0)
|
||||
|
||||
# Track infrastructure expanded state
|
||||
self.infrastructure_expanded = False
|
||||
|
||||
return frame
|
||||
|
||||
def _create_log_section(self, main_vbox):
|
||||
"""Create the collapsible log section at the bottom."""
|
||||
# Log section container
|
||||
log_container = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
log_container.get_style_context().add_class("log-section")
|
||||
main_vbox.pack_end(log_container, False, False, 0)
|
||||
|
||||
# Log header with toggle button
|
||||
log_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
log_header.set_margin_start(12)
|
||||
log_header.set_margin_end(12)
|
||||
log_header.set_margin_top(8)
|
||||
log_header.set_margin_bottom(8)
|
||||
log_container.pack_start(log_header, False, False, 0)
|
||||
|
||||
# Toggle button for log visibility
|
||||
self.log_toggle = Gtk.Button()
|
||||
self.log_toggle.set_relief(Gtk.ReliefStyle.NONE)
|
||||
self.log_toggle.set_can_focus(False)
|
||||
self.log_toggle.set_label("▲")
|
||||
self.log_toggle.set_tooltip_text("Show/hide command log")
|
||||
self.log_toggle.connect("clicked", self._on_log_toggle)
|
||||
log_header.pack_start(self.log_toggle, False, False, 0)
|
||||
|
||||
# Log section label
|
||||
log_section_label = Gtk.Label()
|
||||
log_section_label.set_markup("<b>Command Log</b>")
|
||||
log_section_label.set_halign(Gtk.Align.START)
|
||||
log_header.pack_start(log_section_label, False, False, 0)
|
||||
|
||||
# Create the log view
|
||||
self.log_view = LogView()
|
||||
log_container.pack_start(self.log_view.widget, False, False, 0)
|
||||
|
||||
# Start with log collapsed
|
||||
self.log_expanded = False
|
||||
self.log_view.set_visible(False)
|
||||
|
||||
# Log some initial messages
|
||||
self.log_view.log_info("VPN Manager started")
|
||||
self.log_view.log_info(f"Loaded {len(self.customers)} customers")
|
||||
|
||||
def _on_log_toggle(self, button):
|
||||
"""Toggle log section visibility."""
|
||||
self.log_expanded = not self.log_expanded
|
||||
|
||||
if self.log_expanded:
|
||||
self.log_toggle.set_label("▼")
|
||||
self.log_view.set_visible(True)
|
||||
else:
|
||||
self.log_toggle.set_label("▲")
|
||||
self.log_view.set_visible(False)
|
||||
|
||||
def update_current_location_display(self):
|
||||
"""Update the current location display label."""
|
||||
"""Update the current location display with detailed information."""
|
||||
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>"
|
||||
)
|
||||
|
||||
# Find the actual location object
|
||||
location = None
|
||||
for customer in self.customers:
|
||||
if customer.name == customer_name:
|
||||
location = customer.get_location_by_name(location_name)
|
||||
if location:
|
||||
break
|
||||
|
||||
if location:
|
||||
# Main location info
|
||||
self.location_details_label.set_markup(
|
||||
f"<b>{customer_name}</b> - {location_name}"
|
||||
)
|
||||
|
||||
# Extra info about the location
|
||||
host_count = len(location.hosts)
|
||||
total_hosts = len(location.get_all_hosts_flat())
|
||||
vpn_type = location.vpn_type.value
|
||||
|
||||
extra_text = f"<small>{vpn_type} VPN"
|
||||
if location.external_addresses:
|
||||
if len(location.external_addresses) == 1:
|
||||
extra_text += f" • 🌐 {location.external_addresses[0]}"
|
||||
else:
|
||||
extra_text += f" • 🌐 {len(location.external_addresses)} endpoints"
|
||||
if location.networks:
|
||||
extra_text += f" • 📡 {len(location.networks)} network{'s' if len(location.networks) > 1 else ''}"
|
||||
extra_text += f" • {host_count} hosts"
|
||||
if total_hosts > host_count:
|
||||
extra_text += f" ({total_hosts} total with VMs)"
|
||||
extra_text += "</small>"
|
||||
|
||||
self.location_extra_info.set_markup(extra_text)
|
||||
self.location_extra_info.set_visible(True)
|
||||
|
||||
# Show infrastructure toggle and rebuild infrastructure
|
||||
self.infrastructure_toggle.set_visible(True)
|
||||
self._rebuild_infrastructure_display(location)
|
||||
else:
|
||||
self.location_details_label.set_markup(
|
||||
f"<b>{customer_name}</b> - {location_name}"
|
||||
)
|
||||
self.location_extra_info.set_visible(False)
|
||||
self.infrastructure_toggle.set_visible(False)
|
||||
else:
|
||||
self.current_location_label.set_markup("<i>Current location: Not set</i>")
|
||||
self.location_details_label.set_markup("<i>Not set</i>")
|
||||
self.location_extra_info.set_visible(False)
|
||||
self.infrastructure_toggle.set_visible(False)
|
||||
self.infrastructure_box.set_visible(False)
|
||||
|
||||
def _rebuild_infrastructure_display(self, location):
|
||||
"""Rebuild the infrastructure display for the current location."""
|
||||
# Clear existing infrastructure widgets
|
||||
for child in self.infrastructure_box.get_children():
|
||||
child.destroy()
|
||||
|
||||
# Add network information if available
|
||||
if location.networks or location.external_addresses:
|
||||
network_label = Gtk.Label()
|
||||
network_label.set_markup("<b>Network Configuration</b>")
|
||||
network_label.set_halign(Gtk.Align.START)
|
||||
network_label.set_margin_bottom(4)
|
||||
self.infrastructure_box.pack_start(network_label, False, False, 0)
|
||||
|
||||
# External addresses
|
||||
if location.external_addresses:
|
||||
for i, address in enumerate(location.external_addresses):
|
||||
ext_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
ext_box.set_margin_start(12)
|
||||
self.infrastructure_box.pack_start(
|
||||
ext_box, False, False, 0)
|
||||
|
||||
label_text = "🌐 <b>External:</b>" if i == 0 else "🌐 <b>Backup:</b>"
|
||||
ext_label = Gtk.Label()
|
||||
ext_label.set_markup(
|
||||
f"<small>{label_text} {address}</small>")
|
||||
ext_label.set_halign(Gtk.Align.START)
|
||||
ext_box.pack_start(ext_label, False, False, 0)
|
||||
|
||||
# Internal networks
|
||||
if location.networks:
|
||||
for network in location.networks:
|
||||
net_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
net_box.set_margin_start(12)
|
||||
self.infrastructure_box.pack_start(
|
||||
net_box, False, False, 0)
|
||||
|
||||
net_label = Gtk.Label()
|
||||
net_label.set_markup(
|
||||
f"<small>📡 <b>Network:</b> {network}</small>")
|
||||
net_label.set_halign(Gtk.Align.START)
|
||||
net_box.pack_start(net_label, False, False, 0)
|
||||
|
||||
# Add spacing before infrastructure
|
||||
if location.hosts:
|
||||
spacer = Gtk.Box()
|
||||
spacer.set_size_request(-1, 8)
|
||||
self.infrastructure_box.pack_start(spacer, False, False, 0)
|
||||
|
||||
if not location.hosts:
|
||||
return
|
||||
|
||||
# Add infrastructure label
|
||||
infra_label = Gtk.Label()
|
||||
infra_label.set_markup("<b>Infrastructure</b>")
|
||||
infra_label.set_halign(Gtk.Align.START)
|
||||
infra_label.set_margin_bottom(4)
|
||||
self.infrastructure_box.pack_start(infra_label, False, False, 0)
|
||||
|
||||
# Add hosts
|
||||
for host in location.hosts:
|
||||
host_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
host_box.set_margin_start(12)
|
||||
self.infrastructure_box.pack_start(host_box, False, False, 0)
|
||||
|
||||
# Host type icon
|
||||
host_type_icons = {
|
||||
'Linux': '🐧',
|
||||
'Windows': '🪟',
|
||||
'Windows Server': '🏢',
|
||||
'Proxmox': '📦',
|
||||
'ESXi': '⚙️',
|
||||
'Router': '📡',
|
||||
'Switch': '🔀'
|
||||
}
|
||||
icon = host_type_icons.get(host.host_type.value, '💻')
|
||||
|
||||
# Host info
|
||||
host_label = Gtk.Label()
|
||||
service_count = len(host.services)
|
||||
vm_count = len(host.sub_hosts)
|
||||
|
||||
host_text = f"{icon} <b>{host.name}</b> ({host.ip_address})"
|
||||
if service_count > 0:
|
||||
host_text += f" • {service_count} services"
|
||||
if vm_count > 0:
|
||||
host_text += f" • {vm_count} VMs"
|
||||
|
||||
host_label.set_markup(f"<small>{host_text}</small>")
|
||||
host_label.set_halign(Gtk.Align.START)
|
||||
host_box.pack_start(host_label, False, False, 0)
|
||||
|
||||
# Add sub-hosts (VMs) if any
|
||||
if host.sub_hosts:
|
||||
for vm in host.sub_hosts:
|
||||
vm_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
vm_box.set_margin_start(24)
|
||||
self.infrastructure_box.pack_start(vm_box, False, False, 0)
|
||||
|
||||
vm_icon = host_type_icons.get(vm.host_type.value, '💻')
|
||||
vm_service_count = len(vm.services)
|
||||
|
||||
vm_text = f"{vm_icon} <i>{vm.name}</i> ({vm.ip_address})"
|
||||
if vm_service_count > 0:
|
||||
vm_text += f" • {vm_service_count} services"
|
||||
|
||||
vm_label = Gtk.Label()
|
||||
vm_label.set_markup(f"<small>{vm_text}</small>")
|
||||
vm_label.set_halign(Gtk.Align.START)
|
||||
vm_box.pack_start(vm_label, False, False, 0)
|
||||
|
||||
# Show all widgets (but container might be hidden)
|
||||
self.infrastructure_box.show_all()
|
||||
|
||||
def _on_infrastructure_toggle(self, button):
|
||||
"""Toggle infrastructure section visibility."""
|
||||
self.infrastructure_expanded = not self.infrastructure_expanded
|
||||
|
||||
if self.infrastructure_expanded:
|
||||
self.infrastructure_toggle.set_label("▼")
|
||||
self.infrastructure_box.set_visible(True)
|
||||
else:
|
||||
self.infrastructure_toggle.set_label("▶")
|
||||
self.infrastructure_box.set_visible(False)
|
||||
|
||||
def filter_customers(self, entry):
|
||||
search_term = entry.get_text().strip()
|
||||
|
||||
|
||||
# Check for wildcard - show all customers
|
||||
if search_term == "*":
|
||||
self.filtered_customers = self.customers.copy()
|
||||
@@ -279,8 +561,12 @@ class VPNManagerWindow:
|
||||
# Check hosts and their services in this location
|
||||
def search_hosts(hosts):
|
||||
for host in hosts:
|
||||
# Check IP addresses (search in any of the host's IPs)
|
||||
ip_match = any(search_term_lower in host_ip.ip_address.lower(
|
||||
) for host_ip in host.ip_addresses)
|
||||
|
||||
if (search_term_lower in host.name.lower() or
|
||||
search_term_lower in host.ip_address.lower() or
|
||||
ip_match or
|
||||
search_term_lower in host.host_type.value.lower() or
|
||||
search_term_lower in host.description.lower()):
|
||||
return True
|
||||
@@ -307,11 +593,34 @@ class VPNManagerWindow:
|
||||
self.render_customers()
|
||||
|
||||
def toggle_connection(self, location):
|
||||
location.connected = not location.connected
|
||||
status = "connected to" if location.connected else "disconnected from"
|
||||
print(f"Mock: {status} {location.name} via {location.vpn_type.value}")
|
||||
# Use actual VPN manager
|
||||
if location.connected:
|
||||
# Disconnect
|
||||
self.log_view.log_info(f"Disconnecting from {location.name}...")
|
||||
success = self.vpn_manager.disconnect_vpn(location)
|
||||
if success:
|
||||
location.connected = False
|
||||
self.log_view.log_success(f"Disconnected from {location.name}")
|
||||
else:
|
||||
self.log_view.log_error(
|
||||
f"Failed to disconnect from {location.name}")
|
||||
else:
|
||||
# Connect
|
||||
self.log_view.log_info(
|
||||
f"Connecting to {location.name} via {location.vpn_type.value}...")
|
||||
success = self.vpn_manager.connect_vpn(location)
|
||||
if success:
|
||||
location.connected = True
|
||||
self.log_view.log_success(f"Connected to {location.name}")
|
||||
else:
|
||||
self.log_view.log_error(
|
||||
f"Failed to connect to {location.name}")
|
||||
|
||||
self.render_customers()
|
||||
|
||||
# Update VPN status after connection change
|
||||
self.update_vpn_status()
|
||||
|
||||
def open_service(self, service):
|
||||
# Get the host IP from context - this would need to be passed properly in a real implementation
|
||||
print(
|
||||
@@ -343,6 +652,23 @@ class VPNManagerWindow:
|
||||
self.quit_app()
|
||||
return False
|
||||
|
||||
def update_vpn_status(self):
|
||||
"""Update location connection status from actual VPN manager."""
|
||||
if not self.vpn_manager:
|
||||
return
|
||||
|
||||
# Only update status for active locations to avoid unnecessary nmcli calls
|
||||
for customer in self.customers:
|
||||
for location in customer.locations:
|
||||
if location.active: # Only check active locations
|
||||
try:
|
||||
status = self.vpn_manager.get_connection_status(
|
||||
location)
|
||||
location.connected = (status == VPNStatus.CONNECTED)
|
||||
except VPNConnectionError:
|
||||
# If we can't get status, assume disconnected
|
||||
location.connected = False
|
||||
|
||||
def quit_app(self, _widget=None):
|
||||
# Stop the tray icon
|
||||
if hasattr(self, 'tray_icon'):
|
||||
|
||||
191
models.py
@@ -40,12 +40,45 @@ class VPNType(Enum):
|
||||
IPSEC = "IPSec"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NetworkSegment:
|
||||
"""Represents a network segment with metadata."""
|
||||
name: str # "LAN", "DMZ", "Management"
|
||||
cidr: str # "192.168.1.0/24"
|
||||
vlan_id: Optional[int] = None # VLAN 100
|
||||
zone: str = "general" # "production", "dmz", "management", "guest"
|
||||
gateway: Optional[str] = None # "192.168.1.1"
|
||||
description: str = "" # "Main office network"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PortForwarding:
|
||||
"""Represents a port forwarding rule for external access."""
|
||||
external_port: int # Port on external address (e.g., 8080)
|
||||
# Target internal IP (e.g., "192.168.1.10")
|
||||
internal_ip: str
|
||||
internal_port: int # Target internal port (e.g., 80)
|
||||
protocol: str = "tcp" # "tcp", "udp", or "both"
|
||||
description: str = "" # "Web server access"
|
||||
enabled: bool = True # Whether the forwarding is active
|
||||
|
||||
|
||||
@dataclass
|
||||
class HostIP:
|
||||
"""IP address with network segment context."""
|
||||
ip_address: str
|
||||
network_segment: str # References NetworkSegment.name
|
||||
is_primary: bool = False # Primary interface for this host
|
||||
|
||||
|
||||
@dataclass
|
||||
class Host:
|
||||
"""Represents a physical or virtual host at a location."""
|
||||
name: str
|
||||
ip_address: str
|
||||
host_type: HostType
|
||||
ip_addresses: List[HostIP] = field(default_factory=list)
|
||||
host_type: HostType = HostType.LINUX
|
||||
# Icon name without extension (e.g., 'ubuntu', 'windows')
|
||||
icon: Optional[str] = None
|
||||
description: str = ""
|
||||
services: List[Service] = field(default_factory=list)
|
||||
sub_hosts: List['Host'] = field(
|
||||
@@ -62,6 +95,38 @@ class Host:
|
||||
"""Check if this host has sub-hosts (VMs)."""
|
||||
return len(self.sub_hosts) > 0
|
||||
|
||||
def get_primary_ip(self) -> str:
|
||||
"""Get the primary IP address, or first IP if no primary set."""
|
||||
if not self.ip_addresses:
|
||||
return ""
|
||||
|
||||
# Look for explicitly marked primary
|
||||
for host_ip in self.ip_addresses:
|
||||
if host_ip.is_primary:
|
||||
return host_ip.ip_address
|
||||
|
||||
# Fall back to first IP
|
||||
return self.ip_addresses[0].ip_address
|
||||
|
||||
def get_ip_display(self) -> str:
|
||||
"""Get a display string for IP addresses."""
|
||||
if not self.ip_addresses:
|
||||
return "No IP"
|
||||
elif len(self.ip_addresses) == 1:
|
||||
return self.ip_addresses[0].ip_address
|
||||
else:
|
||||
primary_ip = self.get_primary_ip()
|
||||
return f"{primary_ip} (+{len(self.ip_addresses)-1} more)"
|
||||
|
||||
def get_all_ips(self) -> List[str]:
|
||||
"""Get all IP addresses as a simple list."""
|
||||
return [host_ip.ip_address for host_ip in self.ip_addresses]
|
||||
|
||||
def get_ips_in_segment(self, segment_name: str) -> List[str]:
|
||||
"""Get all IP addresses in a specific network segment."""
|
||||
return [host_ip.ip_address for host_ip in self.ip_addresses
|
||||
if host_ip.network_segment == segment_name]
|
||||
|
||||
|
||||
@dataclass
|
||||
class Location:
|
||||
@@ -72,11 +137,24 @@ class Location:
|
||||
active: bool = False
|
||||
vpn_config: str = "" # Path to VPN config or connection details
|
||||
hosts: List[Host] = field(default_factory=list)
|
||||
|
||||
|
||||
# Enhanced network configuration
|
||||
network_segments: List[NetworkSegment] = field(
|
||||
default_factory=list) # Network segments with rich metadata
|
||||
external_addresses: List[str] = field(
|
||||
default_factory=list) # External VPN endpoints
|
||||
port_forwardings: List[PortForwarding] = field(
|
||||
default_factory=list) # Port forwarding rules
|
||||
|
||||
# Legacy field for backward compatibility (will be deprecated)
|
||||
# Simple network list (legacy)
|
||||
networks: List[str] = field(default_factory=list)
|
||||
|
||||
# VPN connection management fields
|
||||
nmcli_connection_name: Optional[str] = None # NetworkManager connection name
|
||||
# NetworkManager connection name
|
||||
nmcli_connection_name: Optional[str] = None
|
||||
auto_import: bool = True # Auto-import .ovpn file if not in NetworkManager
|
||||
|
||||
|
||||
# Credential storage - can be:
|
||||
# - Passbolt UUID string (for future use)
|
||||
# - Dict with 'username' and 'password' keys
|
||||
@@ -112,6 +190,109 @@ class Location:
|
||||
"""Get all hosts that have sub-hosts (hypervisors)."""
|
||||
return [host for host in self.get_all_hosts_flat() if host.is_hypervisor()]
|
||||
|
||||
def get_segment_by_name(self, segment_name: str) -> Optional[NetworkSegment]:
|
||||
"""Get a network segment by its name."""
|
||||
return next((seg for seg in self.network_segments if seg.name == segment_name), None)
|
||||
|
||||
def get_hosts_in_segment(self, segment_name: str) -> List[Host]:
|
||||
"""Get all hosts that have IPs in the specified network segment."""
|
||||
hosts = []
|
||||
for host in self.get_all_hosts_flat():
|
||||
if any(host_ip.network_segment == segment_name for host_ip in host.ip_addresses):
|
||||
hosts.append(host)
|
||||
return hosts
|
||||
|
||||
def get_segments_by_zone(self, zone: str) -> List[NetworkSegment]:
|
||||
"""Get all network segments in a specific zone."""
|
||||
return [seg for seg in self.network_segments if seg.zone == zone]
|
||||
|
||||
def get_port_forwardings_for_host(self, host_ip: str) -> List[PortForwarding]:
|
||||
"""Get all port forwardings targeting a specific host IP."""
|
||||
return [pf for pf in self.port_forwardings if pf.internal_ip == host_ip and pf.enabled]
|
||||
|
||||
def get_externally_accessible_services(self) -> List[tuple]:
|
||||
"""Get all services accessible from external addresses via port forwarding.
|
||||
|
||||
Returns list of tuples: (external_address, external_port, host, service, port_forwarding)
|
||||
"""
|
||||
accessible_services = []
|
||||
|
||||
for external_addr in self.external_addresses:
|
||||
for port_forward in self.port_forwardings:
|
||||
if not port_forward.enabled:
|
||||
continue
|
||||
|
||||
# Find the host that owns the target IP
|
||||
target_host = None
|
||||
target_service = None
|
||||
|
||||
for host in self.get_all_hosts_flat():
|
||||
host_ips = [hip.ip_address for hip in host.ip_addresses]
|
||||
if port_forward.internal_ip in host_ips:
|
||||
target_host = host
|
||||
|
||||
# Find matching service on this host
|
||||
for service in host.services:
|
||||
if service.port == port_forward.internal_port:
|
||||
target_service = service
|
||||
break
|
||||
break
|
||||
|
||||
if target_host:
|
||||
accessible_services.append((
|
||||
external_addr,
|
||||
port_forward.external_port,
|
||||
target_host,
|
||||
target_service, # May be None if no matching service defined
|
||||
port_forward
|
||||
))
|
||||
|
||||
return accessible_services
|
||||
|
||||
def is_service_externally_accessible(self, host_ip: str, service_port: int) -> bool:
|
||||
"""Check if a specific service is accessible from external addresses."""
|
||||
for pf in self.port_forwardings:
|
||||
if (pf.enabled and
|
||||
pf.internal_ip == host_ip and
|
||||
pf.internal_port == service_port):
|
||||
return True
|
||||
return False
|
||||
|
||||
def is_service_reachable(self, host: 'Host', service: Service) -> bool:
|
||||
"""Check if a service is reachable (either via VPN connection or port forwarding).
|
||||
|
||||
Returns True if:
|
||||
- VPN is connected (all internal services become reachable)
|
||||
- Service has a port forwarding rule enabled
|
||||
"""
|
||||
# If VPN is connected, all services are reachable
|
||||
if self.connected:
|
||||
return True
|
||||
|
||||
# Check if service is externally accessible via port forwarding
|
||||
for host_ip in host.ip_addresses:
|
||||
if self.is_service_externally_accessible(host_ip.ip_address, service.port):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def get_external_url_for_service(self, host: 'Host', service: Service) -> Optional[str]:
|
||||
"""Get the external URL for a service if it has port forwarding.
|
||||
|
||||
Returns the external URL (e.g., "https://vpn.example.com:8006") or None.
|
||||
"""
|
||||
for host_ip in host.ip_addresses:
|
||||
for pf in self.port_forwardings:
|
||||
if (pf.enabled and
|
||||
pf.internal_ip == host_ip.ip_address and
|
||||
pf.internal_port == service.port):
|
||||
# Use first external address if available
|
||||
if self.external_addresses:
|
||||
protocol = "https" if service.port in [
|
||||
443, 8006, 8080] else "http"
|
||||
return f"{protocol}://{self.external_addresses[0]}:{pf.external_port}"
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CustomerService:
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
"""Services package for VPN and password management."""
|
||||
|
||||
from .vpn_manager import VPNManager, VPNConnectionError, VPNStatus, VPNConnection
|
||||
from .passbolt_client import PassboltClient, PassboltError, PassboltCredential
|
||||
from .connection_manager import ConnectionManager, ConnectionConfig
|
||||
|
||||
from .vpn_manager import VPNManager, VPNConnectionError, VPNStatus
|
||||
__all__ = [
|
||||
'VPNManager',
|
||||
'VPNConnection',
|
||||
'VPNConnectionError',
|
||||
'VPNStatus',
|
||||
'PassboltClient',
|
||||
'PassboltCredential',
|
||||
'PassboltError',
|
||||
'ConnectionManager',
|
||||
'ConnectionConfig',
|
||||
]
|
||||
'VPNConnectionError',
|
||||
'VPNStatus'
|
||||
]
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
"""High-level connection manager that integrates VPN and Passbolt."""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .vpn_manager import VPNManager, VPNStatus, VPNConnectionError
|
||||
from .passbolt_client import PassboltClient, PassboltError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConnectionConfig:
|
||||
"""Configuration for a VPN connection."""
|
||||
name: str
|
||||
vpn_config_path: str
|
||||
nmcli_connection_name: Optional[str] = None
|
||||
auto_import: bool = True # Auto-import .ovpn if not in NetworkManager
|
||||
# Credentials can be:
|
||||
# - Passbolt UUID string (for future implementation)
|
||||
# - Dict with 'username' and 'password' keys
|
||||
# - None if no credentials needed
|
||||
vpn_credentials: Optional[dict | str] = None
|
||||
|
||||
|
||||
class ConnectionManager:
|
||||
"""Manages VPN connections with Passbolt credential integration."""
|
||||
|
||||
def __init__(self, use_passbolt: bool = True):
|
||||
"""Initialize the connection manager.
|
||||
|
||||
Args:
|
||||
use_passbolt: Whether to use Passbolt for credentials
|
||||
"""
|
||||
self.vpn_manager = VPNManager()
|
||||
self.passbolt_client = None
|
||||
|
||||
if use_passbolt:
|
||||
try:
|
||||
self.passbolt_client = PassboltClient()
|
||||
logger.info("Passbolt client initialized successfully")
|
||||
except PassboltError as e:
|
||||
logger.warning(f"Passbolt not available: {e}")
|
||||
logger.info("Falling back to manual credential entry")
|
||||
|
||||
def connect_location(self, config: ConnectionConfig,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None) -> None:
|
||||
"""Connect to a VPN location.
|
||||
|
||||
Args:
|
||||
config: Connection configuration
|
||||
username: Override username (if not using Passbolt)
|
||||
password: Override password (if not using Passbolt)
|
||||
"""
|
||||
# Ensure connection exists in NetworkManager
|
||||
connection_name = self._ensure_connection(config)
|
||||
|
||||
# Get credentials - check overrides first, then config
|
||||
if not username or not password:
|
||||
creds_username, creds_password = self._get_credentials_from_config(config)
|
||||
username = username or creds_username
|
||||
password = password or creds_password
|
||||
|
||||
if not username or not password:
|
||||
logger.info(f"No credentials provided for {connection_name}")
|
||||
# nmcli will prompt for credentials
|
||||
|
||||
# Connect
|
||||
try:
|
||||
logger.info(f"Connecting to {connection_name}")
|
||||
self.vpn_manager.connect(connection_name, username, password)
|
||||
logger.info(f"Successfully connected to {connection_name}")
|
||||
except VPNConnectionError as e:
|
||||
logger.error(f"Failed to connect to {connection_name}: {e}")
|
||||
raise
|
||||
|
||||
def disconnect_location(self, config: ConnectionConfig) -> None:
|
||||
"""Disconnect from a VPN location.
|
||||
|
||||
Args:
|
||||
config: Connection configuration
|
||||
"""
|
||||
connection_name = config.nmcli_connection_name or config.name
|
||||
|
||||
if not self.vpn_manager.connection_exists(connection_name):
|
||||
logger.warning(f"Connection {connection_name} does not exist")
|
||||
return
|
||||
|
||||
try:
|
||||
logger.info(f"Disconnecting from {connection_name}")
|
||||
self.vpn_manager.disconnect(connection_name)
|
||||
logger.info(f"Successfully disconnected from {connection_name}")
|
||||
except VPNConnectionError as e:
|
||||
logger.error(f"Failed to disconnect from {connection_name}: {e}")
|
||||
raise
|
||||
|
||||
def get_connection_status(self, config: ConnectionConfig) -> VPNStatus:
|
||||
"""Get the status of a VPN connection.
|
||||
|
||||
Args:
|
||||
config: Connection configuration
|
||||
|
||||
Returns:
|
||||
Current VPN status
|
||||
"""
|
||||
connection_name = config.nmcli_connection_name or config.name
|
||||
|
||||
if not self.vpn_manager.connection_exists(connection_name):
|
||||
return VPNStatus.DISCONNECTED
|
||||
|
||||
return self.vpn_manager.get_status(connection_name)
|
||||
|
||||
def _ensure_connection(self, config: ConnectionConfig) -> str:
|
||||
"""Ensure VPN connection exists in NetworkManager.
|
||||
|
||||
Args:
|
||||
config: Connection configuration
|
||||
|
||||
Returns:
|
||||
Name of the NetworkManager connection
|
||||
"""
|
||||
connection_name = config.nmcli_connection_name or config.name
|
||||
|
||||
# Check if connection already exists
|
||||
if self.vpn_manager.connection_exists(connection_name):
|
||||
logger.debug(f"Connection {connection_name} already exists")
|
||||
return connection_name
|
||||
|
||||
# Import if auto_import is enabled and config file exists
|
||||
if config.auto_import and config.vpn_config_path:
|
||||
vpn_file = Path(config.vpn_config_path)
|
||||
if vpn_file.exists():
|
||||
logger.info(f"Importing VPN configuration from {vpn_file}")
|
||||
imported_name = self.vpn_manager.import_ovpn(
|
||||
str(vpn_file),
|
||||
connection_name
|
||||
)
|
||||
logger.info(f"Imported connection as {imported_name}")
|
||||
return imported_name
|
||||
else:
|
||||
raise VPNConnectionError(
|
||||
f"VPN config file not found: {config.vpn_config_path}"
|
||||
)
|
||||
|
||||
raise VPNConnectionError(
|
||||
f"Connection {connection_name} does not exist and auto-import is disabled"
|
||||
)
|
||||
|
||||
def _get_credentials_from_config(self, config: ConnectionConfig) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Get credentials from the configuration.
|
||||
|
||||
Args:
|
||||
config: Connection configuration
|
||||
|
||||
Returns:
|
||||
Tuple of (username, password) or (None, None)
|
||||
"""
|
||||
if not config.vpn_credentials:
|
||||
return None, None
|
||||
|
||||
# If it's a dict with username/password
|
||||
if isinstance(config.vpn_credentials, dict):
|
||||
username = config.vpn_credentials.get('username')
|
||||
password = config.vpn_credentials.get('password')
|
||||
return username, password
|
||||
|
||||
# If it's a string (Passbolt UUID for future use)
|
||||
if isinstance(config.vpn_credentials, str):
|
||||
# For now, try to use Passbolt if available
|
||||
if self.passbolt_client:
|
||||
try:
|
||||
return self._get_passbolt_credentials(config.vpn_credentials)
|
||||
except (PassboltError, ValueError) as e:
|
||||
logger.warning(f"Failed to get Passbolt credentials: {e}")
|
||||
else:
|
||||
logger.warning(f"Passbolt UUID provided but Passbolt client not available")
|
||||
|
||||
return None, None
|
||||
|
||||
def _get_passbolt_credentials(self, resource_id: str) -> tuple[str, str]:
|
||||
"""Get credentials from Passbolt.
|
||||
|
||||
Args:
|
||||
resource_id: Passbolt resource UUID
|
||||
|
||||
Returns:
|
||||
Tuple of (username, password)
|
||||
"""
|
||||
if not self.passbolt_client:
|
||||
raise ValueError("Passbolt client not initialized")
|
||||
|
||||
try:
|
||||
credential = self.passbolt_client.get_credential(resource_id)
|
||||
|
||||
if not credential.username or not credential.password:
|
||||
raise ValueError(
|
||||
f"Incomplete credentials for resource {resource_id}")
|
||||
|
||||
return credential.username, credential.password
|
||||
|
||||
except PassboltError as e:
|
||||
logger.error(f"Failed to get Passbolt credentials: {e}")
|
||||
raise
|
||||
|
||||
def validate_passbolt_resource(self, resource_id: str) -> bool:
|
||||
"""Validate that a Passbolt resource exists and has required fields.
|
||||
|
||||
Args:
|
||||
resource_id: Passbolt resource UUID
|
||||
|
||||
Returns:
|
||||
True if resource is valid for VPN use
|
||||
"""
|
||||
if not self.passbolt_client:
|
||||
return False
|
||||
|
||||
try:
|
||||
credential = self.passbolt_client.get_credential(resource_id)
|
||||
return bool(credential.username and credential.password)
|
||||
except PassboltError:
|
||||
return False
|
||||
|
||||
def import_all_configs(self, configs: list[ConnectionConfig]) -> Dict[str, bool]:
|
||||
"""Import multiple VPN configurations.
|
||||
|
||||
Args:
|
||||
configs: List of connection configurations
|
||||
|
||||
Returns:
|
||||
Dictionary mapping connection names to success status
|
||||
"""
|
||||
results = {}
|
||||
|
||||
for config in configs:
|
||||
try:
|
||||
connection_name = self._ensure_connection(config)
|
||||
results[connection_name] = True
|
||||
logger.info(f"Successfully imported {connection_name}")
|
||||
except VPNConnectionError as e:
|
||||
results[config.name] = False
|
||||
logger.error(f"Failed to import {config.name}: {e}")
|
||||
|
||||
return results
|
||||
|
||||
def cleanup_connection(self, config: ConnectionConfig,
|
||||
remove_from_nm: bool = False) -> None:
|
||||
"""Clean up a VPN connection.
|
||||
|
||||
Args:
|
||||
config: Connection configuration
|
||||
remove_from_nm: Whether to remove from NetworkManager
|
||||
"""
|
||||
connection_name = config.nmcli_connection_name or config.name
|
||||
|
||||
# Disconnect if connected
|
||||
if self.get_connection_status(config) == VPNStatus.CONNECTED:
|
||||
self.disconnect_location(config)
|
||||
|
||||
# Remove from NetworkManager if requested
|
||||
if remove_from_nm and self.vpn_manager.connection_exists(connection_name):
|
||||
logger.info(f"Removing {connection_name} from NetworkManager")
|
||||
self.vpn_manager.delete_connection(connection_name)
|
||||
@@ -1,369 +0,0 @@
|
||||
"""Passbolt CLI integration for secure credential management."""
|
||||
|
||||
import subprocess
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, List, Dict, Any
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class PassboltResourceType(Enum):
|
||||
"""Types of resources in Passbolt."""
|
||||
PASSWORD = "password"
|
||||
PASSWORD_WITH_DESCRIPTION = "password-with-description"
|
||||
PASSWORD_STRING = "password-string"
|
||||
TOTP = "totp"
|
||||
|
||||
|
||||
class PassboltError(Exception):
|
||||
"""Exception raised for Passbolt operations."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class PassboltCredential:
|
||||
"""Represents credentials retrieved from Passbolt."""
|
||||
resource_id: str
|
||||
name: str
|
||||
username: Optional[str] = None
|
||||
password: Optional[str] = None
|
||||
uri: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
resource_type: PassboltResourceType = PassboltResourceType.PASSWORD
|
||||
|
||||
|
||||
@dataclass
|
||||
class PassboltResource:
|
||||
"""Represents a Passbolt resource."""
|
||||
id: str
|
||||
name: str
|
||||
username: Optional[str] = None
|
||||
uri: Optional[str] = None
|
||||
resource_type: str = "password"
|
||||
folder_parent_id: Optional[str] = None
|
||||
personal: bool = False
|
||||
|
||||
|
||||
class PassboltClient:
|
||||
"""Client for interacting with Passbolt through the CLI."""
|
||||
|
||||
def __init__(self, passbolt_cli_path: str = "passbolt"):
|
||||
"""Initialize Passbolt client.
|
||||
|
||||
Args:
|
||||
passbolt_cli_path: Path to the passbolt CLI executable
|
||||
"""
|
||||
self.cli_path = passbolt_cli_path
|
||||
self._check_cli_available()
|
||||
self._check_authentication()
|
||||
|
||||
def _check_cli_available(self) -> None:
|
||||
"""Check if Passbolt CLI is available."""
|
||||
try:
|
||||
subprocess.run([self.cli_path, '--version'],
|
||||
capture_output=True, check=True)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
raise PassboltError(
|
||||
f"Passbolt CLI not found at '{self.cli_path}'. "
|
||||
"Please install: https://github.com/passbolt/go-passbolt-cli"
|
||||
)
|
||||
|
||||
def _check_authentication(self) -> None:
|
||||
"""Check if authenticated with Passbolt."""
|
||||
try:
|
||||
# Try to list resources to check auth
|
||||
self._run_passbolt(['list', '--json'], check=True)
|
||||
except PassboltError:
|
||||
raise PassboltError(
|
||||
"Not authenticated with Passbolt. "
|
||||
"Please run: passbolt auth login"
|
||||
)
|
||||
|
||||
def _run_passbolt(self, args: List[str], check: bool = True) -> subprocess.CompletedProcess:
|
||||
"""Run a Passbolt CLI command.
|
||||
|
||||
Args:
|
||||
args: Command arguments
|
||||
check: Whether to check return code
|
||||
|
||||
Returns:
|
||||
Completed process result
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[self.cli_path] + args,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check
|
||||
)
|
||||
return result
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise PassboltError(f"Passbolt command failed: {e.stderr}")
|
||||
|
||||
def get_credential(self, resource_id: str) -> PassboltCredential:
|
||||
"""Get a credential by resource ID.
|
||||
|
||||
Args:
|
||||
resource_id: UUID of the Passbolt resource
|
||||
|
||||
Returns:
|
||||
PassboltCredential object with username and password
|
||||
"""
|
||||
# Get the full resource
|
||||
result = self._run_passbolt(['get', '--id', resource_id, '--json'])
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
raise PassboltError(f"Failed to parse Passbolt response")
|
||||
|
||||
# Extract fields based on resource type
|
||||
credential = PassboltCredential(
|
||||
resource_id=resource_id,
|
||||
name=data.get('name', ''),
|
||||
username=data.get('username'),
|
||||
password=data.get('password'),
|
||||
uri=data.get('uri'),
|
||||
description=data.get('description')
|
||||
)
|
||||
|
||||
# Determine resource type
|
||||
if 'resource_type' in data:
|
||||
try:
|
||||
credential.resource_type = PassboltResourceType(
|
||||
data['resource_type'])
|
||||
except ValueError:
|
||||
pass # Keep default
|
||||
|
||||
return credential
|
||||
|
||||
def get_field(self, resource_id: str, field: str) -> str:
|
||||
"""Get a specific field from a resource.
|
||||
|
||||
Args:
|
||||
resource_id: UUID of the Passbolt resource
|
||||
field: Field name (e.g., 'password', 'username', 'uri')
|
||||
|
||||
Returns:
|
||||
Field value as string
|
||||
"""
|
||||
result = self._run_passbolt(
|
||||
['get', '--id', resource_id, '--field', field])
|
||||
return result.stdout.strip()
|
||||
|
||||
def get_password(self, resource_id: str) -> str:
|
||||
"""Get just the password for a resource.
|
||||
|
||||
Args:
|
||||
resource_id: UUID of the Passbolt resource
|
||||
|
||||
Returns:
|
||||
Password string
|
||||
"""
|
||||
return self.get_field(resource_id, 'password')
|
||||
|
||||
def get_username(self, resource_id: str) -> str:
|
||||
"""Get just the username for a resource.
|
||||
|
||||
Args:
|
||||
resource_id: UUID of the Passbolt resource
|
||||
|
||||
Returns:
|
||||
Username string
|
||||
"""
|
||||
return self.get_field(resource_id, 'username')
|
||||
|
||||
def list_resources(self, folder_id: Optional[str] = None,
|
||||
search: Optional[str] = None) -> List[PassboltResource]:
|
||||
"""List available resources.
|
||||
|
||||
Args:
|
||||
folder_id: Optional folder ID to filter by
|
||||
search: Optional search term
|
||||
|
||||
Returns:
|
||||
List of PassboltResource objects
|
||||
"""
|
||||
args = ['list', '--json']
|
||||
|
||||
if folder_id:
|
||||
args.extend(['--folder', folder_id])
|
||||
if search:
|
||||
args.extend(['--filter', search])
|
||||
|
||||
result = self._run_passbolt(args)
|
||||
|
||||
try:
|
||||
data = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
resources = []
|
||||
for item in data:
|
||||
resources.append(PassboltResource(
|
||||
id=item['id'],
|
||||
name=item.get('name', ''),
|
||||
username=item.get('username'),
|
||||
uri=item.get('uri'),
|
||||
resource_type=item.get('resource_type', 'password'),
|
||||
folder_parent_id=item.get('folder_parent_id'),
|
||||
personal=item.get('personal', False)
|
||||
))
|
||||
|
||||
return resources
|
||||
|
||||
def find_resource_by_name(self, name: str) -> Optional[PassboltResource]:
|
||||
"""Find a resource by name.
|
||||
|
||||
Args:
|
||||
name: Name of the resource to find
|
||||
|
||||
Returns:
|
||||
First matching PassboltResource or None
|
||||
"""
|
||||
resources = self.list_resources(search=name)
|
||||
for resource in resources:
|
||||
if resource.name == name:
|
||||
return resource
|
||||
return None
|
||||
|
||||
def create_resource(self, name: str, username: str, password: str,
|
||||
uri: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
folder_id: Optional[str] = None) -> str:
|
||||
"""Create a new password resource.
|
||||
|
||||
Args:
|
||||
name: Resource name
|
||||
username: Username
|
||||
password: Password
|
||||
uri: Optional URI/URL
|
||||
description: Optional description
|
||||
folder_id: Optional folder to place resource in
|
||||
|
||||
Returns:
|
||||
ID of created resource
|
||||
"""
|
||||
args = ['create', 'resource',
|
||||
'--name', name,
|
||||
'--username', username,
|
||||
'--password', password]
|
||||
|
||||
if uri:
|
||||
args.extend(['--uri', uri])
|
||||
if description:
|
||||
args.extend(['--description', description])
|
||||
if folder_id:
|
||||
args.extend(['--folder', folder_id])
|
||||
|
||||
result = self._run_passbolt(args)
|
||||
|
||||
# Parse the ID from output
|
||||
# Output format: "Resource created: <id>"
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'created' in line.lower() and ':' in line:
|
||||
parts = line.split(':', 1)
|
||||
if len(parts) == 2:
|
||||
return parts[1].strip()
|
||||
|
||||
raise PassboltError("Failed to parse created resource ID")
|
||||
|
||||
def update_resource(self, resource_id: str,
|
||||
name: Optional[str] = None,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
uri: Optional[str] = None,
|
||||
description: Optional[str] = None) -> None:
|
||||
"""Update an existing resource.
|
||||
|
||||
Args:
|
||||
resource_id: ID of resource to update
|
||||
name: New name (optional)
|
||||
username: New username (optional)
|
||||
password: New password (optional)
|
||||
uri: New URI (optional)
|
||||
description: New description (optional)
|
||||
"""
|
||||
args = ['update', 'resource', '--id', resource_id]
|
||||
|
||||
if name:
|
||||
args.extend(['--name', name])
|
||||
if username:
|
||||
args.extend(['--username', username])
|
||||
if password:
|
||||
args.extend(['--password', password])
|
||||
if uri:
|
||||
args.extend(['--uri', uri])
|
||||
if description:
|
||||
args.extend(['--description', description])
|
||||
|
||||
self._run_passbolt(args)
|
||||
|
||||
def delete_resource(self, resource_id: str) -> None:
|
||||
"""Delete a resource.
|
||||
|
||||
Args:
|
||||
resource_id: ID of resource to delete
|
||||
"""
|
||||
self._run_passbolt(['delete', 'resource', '--id', resource_id])
|
||||
|
||||
def share_resource(self, resource_id: str, user_id: str,
|
||||
permission: str = "read") -> None:
|
||||
"""Share a resource with another user.
|
||||
|
||||
Args:
|
||||
resource_id: ID of resource to share
|
||||
user_id: ID of user to share with
|
||||
permission: Permission level ('read', 'update', 'owner')
|
||||
"""
|
||||
self._run_passbolt([
|
||||
'share', 'resource',
|
||||
'--id', resource_id,
|
||||
'--user', user_id,
|
||||
'--permission', permission
|
||||
])
|
||||
|
||||
def list_folders(self) -> List[Dict[str, Any]]:
|
||||
"""List all folders.
|
||||
|
||||
Returns:
|
||||
List of folder dictionaries
|
||||
"""
|
||||
result = self._run_passbolt(['list', 'folder', '--json'])
|
||||
|
||||
try:
|
||||
return json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
|
||||
def get_folder_by_name(self, name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find a folder by name.
|
||||
|
||||
Args:
|
||||
name: Folder name to search for
|
||||
|
||||
Returns:
|
||||
Folder dictionary or None
|
||||
"""
|
||||
folders = self.list_folders()
|
||||
for folder in folders:
|
||||
if folder.get('name') == name:
|
||||
return folder
|
||||
return None
|
||||
|
||||
def validate_resource_id(self, resource_id: str) -> bool:
|
||||
"""Check if a resource ID exists and is accessible.
|
||||
|
||||
Args:
|
||||
resource_id: UUID of the resource
|
||||
|
||||
Returns:
|
||||
True if resource exists and is accessible
|
||||
"""
|
||||
try:
|
||||
self._run_passbolt(['get', '--id', resource_id, '--field', 'name'])
|
||||
return True
|
||||
except PassboltError:
|
||||
return False
|
||||
@@ -1,13 +1,13 @@
|
||||
"""VPN connection management using NetworkManager (nmcli)."""
|
||||
"""Enhanced VPN management with VPNTray naming and route control."""
|
||||
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
import re
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, List
|
||||
from typing import Optional, List
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from models import Location
|
||||
|
||||
|
||||
class VPNStatus(Enum):
|
||||
@@ -26,286 +26,482 @@ class VPNConnectionError(Exception):
|
||||
|
||||
|
||||
@dataclass
|
||||
class VPNConnection:
|
||||
"""Represents a NetworkManager VPN connection."""
|
||||
class VPNConnectionInfo:
|
||||
"""Information about a VPN connection."""
|
||||
name: str
|
||||
uuid: str
|
||||
type: str
|
||||
vpntray_name: str # Our custom name with vpntray_ prefix
|
||||
status: VPNStatus
|
||||
device: Optional[str] = None
|
||||
state: VPNStatus = VPNStatus.UNKNOWN
|
||||
vpn_type: Optional[str] = None # OpenVPN, WireGuard, etc.
|
||||
routes: List[str] = None # List of routes added
|
||||
|
||||
|
||||
class VPNManager:
|
||||
"""Manages VPN connections through NetworkManager CLI (nmcli)."""
|
||||
"""Enhanced VPN manager with VPNTray naming and route management."""
|
||||
|
||||
VPNTRAY_PREFIX = "vpntray_"
|
||||
VPN_CONFIG_DIR = Path.home() / ".vpntray" / "vpn"
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize VPN manager and check for nmcli availability."""
|
||||
"""Initialize VPN manager."""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self._check_nmcli_available()
|
||||
self._ensure_vpn_config_dir()
|
||||
|
||||
def _check_nmcli_available(self) -> None:
|
||||
"""Check if nmcli is available on the system."""
|
||||
"""Check if nmcli is available."""
|
||||
try:
|
||||
subprocess.run(['nmcli', '--version'],
|
||||
capture_output=True, check=True)
|
||||
except (subprocess.CalledProcessError, FileNotFoundError):
|
||||
raise VPNConnectionError(
|
||||
"nmcli is not available. Please install NetworkManager.")
|
||||
"nmcli is not available. Install NetworkManager.")
|
||||
|
||||
def _ensure_vpn_config_dir(self) -> None:
|
||||
"""Ensure VPN config directory exists."""
|
||||
self.VPN_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _run_nmcli(self, args: List[str], check: bool = True, timeout: int = 30) -> subprocess.CompletedProcess:
|
||||
"""Run nmcli command with logging and timeout."""
|
||||
command = ['nmcli'] + args
|
||||
command_str = ' '.join(command)
|
||||
|
||||
def _run_nmcli(self, args: List[str], check: bool = True) -> subprocess.CompletedProcess:
|
||||
"""Run an nmcli command with error handling."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
['nmcli'] + args,
|
||||
command,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=check
|
||||
check=check,
|
||||
timeout=timeout # Add timeout to prevent hanging
|
||||
)
|
||||
|
||||
self.logger.debug(f"Command: {command_str}")
|
||||
if result.stdout.strip():
|
||||
self.logger.debug(f"Output: {result.stdout.strip()}")
|
||||
if result.stderr.strip():
|
||||
self.logger.warning(f"Stderr: {result.stderr.strip()}")
|
||||
if result.returncode == 0:
|
||||
self.logger.debug("Command completed successfully")
|
||||
else:
|
||||
self.logger.error(
|
||||
f"Command exited with code: {result.returncode}")
|
||||
|
||||
return result
|
||||
except subprocess.TimeoutExpired:
|
||||
self.logger.error(
|
||||
f"Command timed out after {timeout}s: {command_str}")
|
||||
raise VPNConnectionError(
|
||||
f"nmcli command timed out after {timeout} seconds")
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise VPNConnectionError(f"nmcli command failed: {e.stderr}")
|
||||
|
||||
def import_ovpn(self, ovpn_path: str, connection_name: Optional[str] = None) -> str:
|
||||
"""Import an OpenVPN configuration file.
|
||||
|
||||
Args:
|
||||
ovpn_path: Path to the .ovpn configuration file
|
||||
connection_name: Optional custom name for the connection
|
||||
|
||||
Returns:
|
||||
The name of the imported connection
|
||||
"""
|
||||
ovpn_file = Path(ovpn_path)
|
||||
if not ovpn_file.exists():
|
||||
self.logger.debug(f"Failed command: {command_str}")
|
||||
if e.stdout and e.stdout.strip():
|
||||
self.logger.debug(f"Output: {e.stdout.strip()}")
|
||||
if e.stderr and e.stderr.strip():
|
||||
self.logger.error(f"Error: {e.stderr.strip()}")
|
||||
error_details = e.stderr or str(e)
|
||||
raise VPNConnectionError(
|
||||
f"OpenVPN config file not found: {ovpn_path}")
|
||||
f"nmcli command failed (exit code {e.returncode}): {error_details}")
|
||||
|
||||
# Import the configuration
|
||||
result = self._run_nmcli([
|
||||
'connection', 'import', 'type', 'openvpn', 'file', str(ovpn_file)
|
||||
])
|
||||
def _get_vpntray_connection_name(self, config_filename: str) -> str:
|
||||
"""Generate VPNTray-specific connection name."""
|
||||
# Remove extension and sanitize
|
||||
base_name = Path(config_filename).stem
|
||||
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '_', base_name)
|
||||
return f"{self.VPNTRAY_PREFIX}{sanitized}"
|
||||
|
||||
# Extract connection name from output
|
||||
# nmcli typically outputs: "Connection 'name' (uuid) successfully added."
|
||||
match = re.search(r"Connection '([^']+)'", result.stdout)
|
||||
if not match:
|
||||
raise VPNConnectionError(
|
||||
"Failed to parse imported connection name")
|
||||
|
||||
imported_name = match.group(1)
|
||||
|
||||
# Rename if custom name provided
|
||||
if connection_name and connection_name != imported_name:
|
||||
self._run_nmcli([
|
||||
'connection', 'modify', imported_name,
|
||||
'connection.id', connection_name
|
||||
])
|
||||
return connection_name
|
||||
|
||||
return imported_name
|
||||
|
||||
def connect(self, connection_name: str,
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None) -> None:
|
||||
"""Connect to a VPN.
|
||||
|
||||
Args:
|
||||
connection_name: Name of the NetworkManager connection
|
||||
username: Optional username for authentication
|
||||
password: Optional password for authentication
|
||||
"""
|
||||
if username and password:
|
||||
# Create temporary secrets file
|
||||
with tempfile.NamedTemporaryFile(mode='w', delete=False) as f:
|
||||
f.write(f"vpn.secrets.password:{password}\n")
|
||||
if username:
|
||||
f.write(f"vpn.data.username:{username}\n")
|
||||
secrets_file = f.name
|
||||
|
||||
try:
|
||||
self._run_nmcli([
|
||||
'connection', 'up', connection_name,
|
||||
'passwd-file', secrets_file
|
||||
])
|
||||
finally:
|
||||
# Always clean up secrets file
|
||||
os.unlink(secrets_file)
|
||||
else:
|
||||
# Connect without credentials (will prompt if needed)
|
||||
self._run_nmcli(['connection', 'up', connection_name])
|
||||
|
||||
def disconnect(self, connection_name: str) -> None:
|
||||
"""Disconnect from a VPN.
|
||||
|
||||
Args:
|
||||
connection_name: Name of the NetworkManager connection
|
||||
"""
|
||||
self._run_nmcli(['connection', 'down', connection_name])
|
||||
|
||||
def get_status(self, connection_name: str) -> VPNStatus:
|
||||
"""Get the status of a VPN connection.
|
||||
|
||||
Args:
|
||||
connection_name: Name of the NetworkManager connection
|
||||
|
||||
Returns:
|
||||
Current status of the VPN connection
|
||||
"""
|
||||
result = self._run_nmcli(
|
||||
['connection', 'show', '--active'],
|
||||
check=False
|
||||
)
|
||||
|
||||
if connection_name in result.stdout:
|
||||
# Parse the actual state
|
||||
state_result = self._run_nmcli(
|
||||
['connection', 'show', connection_name],
|
||||
check=False
|
||||
)
|
||||
|
||||
if 'GENERAL.STATE:' in state_result.stdout:
|
||||
if 'activated' in state_result.stdout:
|
||||
return VPNStatus.CONNECTED
|
||||
elif 'activating' in state_result.stdout:
|
||||
return VPNStatus.CONNECTING
|
||||
elif 'deactivating' in state_result.stdout:
|
||||
return VPNStatus.DISCONNECTING
|
||||
|
||||
return VPNStatus.DISCONNECTED
|
||||
|
||||
def list_connections(self, vpn_only: bool = True) -> List[VPNConnection]:
|
||||
"""List all NetworkManager connections.
|
||||
|
||||
Args:
|
||||
vpn_only: If True, only return VPN connections
|
||||
|
||||
Returns:
|
||||
List of VPNConnection objects
|
||||
"""
|
||||
args = ['connection', 'show']
|
||||
if vpn_only:
|
||||
args.extend(['--type', 'vpn'])
|
||||
|
||||
result = self._run_nmcli(args, check=False)
|
||||
def get_vpn_config_path(self, filename: str) -> Path:
|
||||
"""Get full path to VPN config file."""
|
||||
return self.VPN_CONFIG_DIR / filename
|
||||
|
||||
def list_vpntray_connections(self) -> List[VPNConnectionInfo]:
|
||||
"""List all VPNTray-managed connections."""
|
||||
connections = []
|
||||
for line in result.stdout.strip().split('\n')[1:]: # Skip header
|
||||
if not line:
|
||||
continue
|
||||
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
name = parts[0]
|
||||
uuid = parts[1]
|
||||
conn_type = parts[2]
|
||||
device = parts[3] if parts[3] != '--' else None
|
||||
try:
|
||||
result = self._run_nmcli(['connection', 'show'])
|
||||
for line in result.stdout.strip().split('\\n'):
|
||||
if self.VPNTRAY_PREFIX in line:
|
||||
parts = line.split()
|
||||
if len(parts) >= 4:
|
||||
name = parts[0]
|
||||
uuid = parts[1]
|
||||
device = parts[3] if parts[3] != '--' else None
|
||||
|
||||
# Get current status
|
||||
status = self.get_status(name)
|
||||
# Get detailed status
|
||||
status = self._get_connection_status(name)
|
||||
|
||||
connections.append(VPNConnection(
|
||||
name=name,
|
||||
uuid=uuid,
|
||||
type=conn_type,
|
||||
device=device,
|
||||
state=status
|
||||
))
|
||||
connections.append(VPNConnectionInfo(
|
||||
name=name,
|
||||
uuid=uuid,
|
||||
vpntray_name=name,
|
||||
status=status,
|
||||
device=device
|
||||
))
|
||||
except VPNConnectionError:
|
||||
pass # No connections or nmcli error
|
||||
|
||||
return connections
|
||||
|
||||
def delete_connection(self, connection_name: str) -> None:
|
||||
"""Delete a NetworkManager connection.
|
||||
def _get_connection_status(self, connection_name: str) -> VPNStatus:
|
||||
"""Get the status of a specific connection."""
|
||||
try:
|
||||
result = self._run_nmcli(['connection', 'show', connection_name])
|
||||
|
||||
Args:
|
||||
connection_name: Name of the connection to delete
|
||||
"""
|
||||
self._run_nmcli(['connection', 'delete', connection_name])
|
||||
# Parse connection state from output
|
||||
for line in result.stdout.split('\\n'):
|
||||
if 'GENERAL.STATE:' in line:
|
||||
state = line.split(':')[1].strip()
|
||||
if 'activated' in state.lower():
|
||||
return VPNStatus.CONNECTED
|
||||
elif 'activating' in state.lower():
|
||||
return VPNStatus.CONNECTING
|
||||
elif 'deactivating' in state.lower():
|
||||
return VPNStatus.DISCONNECTING
|
||||
else:
|
||||
return VPNStatus.DISCONNECTED
|
||||
except VPNConnectionError:
|
||||
pass
|
||||
|
||||
def connection_exists(self, connection_name: str) -> bool:
|
||||
"""Check if a connection exists.
|
||||
return VPNStatus.UNKNOWN
|
||||
|
||||
Args:
|
||||
connection_name: Name of the connection to check
|
||||
def import_vpn_config(self, location: Location) -> str:
|
||||
"""Import VPN configuration for a location with VPNTray naming."""
|
||||
config_path = self.get_vpn_config_path(location.vpn_config)
|
||||
|
||||
Returns:
|
||||
True if the connection exists
|
||||
"""
|
||||
result = self._run_nmcli(
|
||||
['connection', 'show', connection_name],
|
||||
check=False
|
||||
)
|
||||
return result.returncode == 0
|
||||
if not config_path.exists():
|
||||
raise VPNConnectionError(f"VPN config not found: {config_path}")
|
||||
|
||||
def modify_connection(self, connection_name: str,
|
||||
settings: Dict[str, str]) -> None:
|
||||
"""Modify connection settings.
|
||||
self.logger.info(
|
||||
f"Config file exists: {config_path} ({config_path.stat().st_size} bytes)")
|
||||
|
||||
Args:
|
||||
connection_name: Name of the connection to modify
|
||||
settings: Dictionary of setting key-value pairs
|
||||
e.g., {'vpn.data.comp-lzo': 'yes'}
|
||||
"""
|
||||
for key, value in settings.items():
|
||||
vpntray_name = self._get_vpntray_connection_name(location.vpn_config)
|
||||
|
||||
# Check if already imported
|
||||
if self._get_connection_by_name(vpntray_name):
|
||||
self.logger.info(f"Connection already imported: {vpntray_name}")
|
||||
return vpntray_name
|
||||
|
||||
# Import based on VPN type
|
||||
self.logger.info(
|
||||
f"Importing {location.vpn_type.value} config: {config_path.name}")
|
||||
|
||||
if location.vpn_type.value == "OpenVPN":
|
||||
return self._import_openvpn(config_path, vpntray_name, location)
|
||||
elif location.vpn_type.value == "WireGuard":
|
||||
return self._import_wireguard(config_path, vpntray_name, location)
|
||||
else:
|
||||
raise VPNConnectionError(
|
||||
f"Unsupported VPN type: {location.vpn_type.value}")
|
||||
|
||||
def _import_openvpn(self, config_path: Path, vpntray_name: str, location: Location) -> str:
|
||||
"""Import OpenVPN configuration with route control."""
|
||||
# Import the config file first (nmcli will auto-generate a name)
|
||||
import_args = [
|
||||
'connection', 'import', 'type', 'openvpn',
|
||||
'file', str(config_path)
|
||||
]
|
||||
self.logger.info(f"Running nmcli import: {' '.join(import_args)}")
|
||||
|
||||
try:
|
||||
result = self._run_nmcli(import_args)
|
||||
|
||||
# Extract the auto-generated connection name from the output
|
||||
# nmcli outputs: "Connection 'name' (uuid) successfully added."
|
||||
import re
|
||||
match = re.search(r"Connection '([^']+)'", result.stdout)
|
||||
if not match:
|
||||
raise VPNConnectionError(
|
||||
"Failed to parse imported connection name from nmcli output")
|
||||
|
||||
auto_generated_name = match.group(1)
|
||||
self.logger.info(
|
||||
f"Config imported with auto name: {auto_generated_name}")
|
||||
|
||||
# Rename to our VPNTray naming convention
|
||||
rename_args = [
|
||||
'connection', 'modify', auto_generated_name,
|
||||
'connection.id', vpntray_name
|
||||
]
|
||||
self.logger.info(f"Renaming to: {vpntray_name}")
|
||||
|
||||
self._run_nmcli(rename_args)
|
||||
self.logger.info(
|
||||
f"OpenVPN config imported as {vpntray_name}")
|
||||
|
||||
except VPNConnectionError as e:
|
||||
self.logger.error(f"OpenVPN import failed: {e}")
|
||||
raise
|
||||
|
||||
# Configure credentials immediately after import if provided
|
||||
if location.vpn_credentials:
|
||||
self._configure_credentials(vpntray_name, location)
|
||||
|
||||
# Configure the connection to not route everything by default
|
||||
self._configure_connection_routes(vpntray_name, location)
|
||||
|
||||
return vpntray_name
|
||||
|
||||
def _import_wireguard(self, config_path: Path, vpntray_name: str, location: Location) -> str:
|
||||
"""Import WireGuard configuration with route control."""
|
||||
# Import the config file first (nmcli will auto-generate a name)
|
||||
import_args = [
|
||||
'connection', 'import', 'type', 'wireguard',
|
||||
'file', str(config_path)
|
||||
]
|
||||
self.logger.info(
|
||||
f"Running nmcli import: {' '.join(import_args)}")
|
||||
|
||||
try:
|
||||
result = self._run_nmcli(import_args)
|
||||
|
||||
# Extract the auto-generated connection name from the output
|
||||
# nmcli outputs: "Connection 'name' (uuid) successfully added."
|
||||
import re
|
||||
match = re.search(r"Connection '([^']+)'", result.stdout)
|
||||
if not match:
|
||||
raise VPNConnectionError(
|
||||
"Failed to parse imported connection name from nmcli output")
|
||||
|
||||
auto_generated_name = match.group(1)
|
||||
self.logger.info(
|
||||
f"Config imported with auto name: {auto_generated_name}")
|
||||
|
||||
# Rename to our VPNTray naming convention
|
||||
rename_args = [
|
||||
'connection', 'modify', auto_generated_name,
|
||||
'connection.id', vpntray_name
|
||||
]
|
||||
self.logger.info(f"Renaming to: {vpntray_name}")
|
||||
|
||||
self._run_nmcli(rename_args)
|
||||
self.logger.info(
|
||||
f"WireGuard config imported as {vpntray_name}")
|
||||
|
||||
except VPNConnectionError as e:
|
||||
self.logger.error(f"WireGuard import failed: {e}")
|
||||
raise
|
||||
|
||||
# Configure credentials immediately after import if provided
|
||||
if location.vpn_credentials:
|
||||
self._configure_credentials(vpntray_name, location)
|
||||
|
||||
# Configure routes
|
||||
self._configure_connection_routes(vpntray_name, location)
|
||||
|
||||
return vpntray_name
|
||||
|
||||
def _configure_connection_routes(self, connection_name: str, location: Location) -> None:
|
||||
"""Configure connection to only route specified network segments."""
|
||||
try:
|
||||
# Disable automatic default route
|
||||
self._run_nmcli([
|
||||
'connection', 'modify', connection_name,
|
||||
key, value
|
||||
'ipv4.never-default', 'true'
|
||||
])
|
||||
|
||||
def get_connection_details(self, connection_name: str) -> Dict[str, str]:
|
||||
"""Get detailed information about a connection.
|
||||
# Add routes for each network segment
|
||||
routes = []
|
||||
for segment in location.network_segments:
|
||||
# Add route for the network segment
|
||||
routes.append(segment.cidr)
|
||||
|
||||
Args:
|
||||
connection_name: Name of the connection
|
||||
if routes:
|
||||
routes_str = ','.join(routes)
|
||||
self._run_nmcli([
|
||||
'connection', 'modify', connection_name,
|
||||
'ipv4.routes', routes_str
|
||||
])
|
||||
self.logger.info(
|
||||
f"Configured routes for {connection_name}: {routes_str}")
|
||||
|
||||
Returns:
|
||||
Dictionary of connection properties
|
||||
"""
|
||||
result = self._run_nmcli(['connection', 'show', connection_name])
|
||||
except VPNConnectionError as e:
|
||||
self.logger.error(f"Failed to configure routes: {e}")
|
||||
# Don't fail the import, just log the error
|
||||
|
||||
details = {}
|
||||
for line in result.stdout.strip().split('\n'):
|
||||
if ':' in line:
|
||||
key, value = line.split(':', 1)
|
||||
details[key.strip()] = value.strip()
|
||||
|
||||
return details
|
||||
|
||||
def get_active_vpn_interface(self, connection_name: str) -> Optional[str]:
|
||||
"""Get the network interface used by an active VPN connection.
|
||||
|
||||
Args:
|
||||
connection_name: Name of the VPN connection
|
||||
|
||||
Returns:
|
||||
Interface name (e.g., 'tun0') or None if not connected
|
||||
"""
|
||||
if self.get_status(connection_name) != VPNStatus.CONNECTED:
|
||||
def _get_connection_by_name(self, name: str) -> Optional[VPNConnectionInfo]:
|
||||
"""Get connection info by name."""
|
||||
try:
|
||||
# Check if connection exists (simple and fast)
|
||||
result = self._run_nmcli(['connection', 'show', name], check=False)
|
||||
if result.returncode == 0:
|
||||
# Connection exists, create minimal info object
|
||||
return VPNConnectionInfo(
|
||||
name=name,
|
||||
uuid="unknown",
|
||||
vpntray_name=name,
|
||||
status=VPNStatus.UNKNOWN # Status will be checked when needed
|
||||
)
|
||||
return None
|
||||
except VPNConnectionError:
|
||||
return None
|
||||
|
||||
details = self.get_connection_details(connection_name)
|
||||
return details.get('GENERAL.DEVICES')
|
||||
def connect_vpn(self, location: Location) -> bool:
|
||||
"""Connect to VPN for a location."""
|
||||
try:
|
||||
vpntray_name = self._get_vpntray_connection_name(
|
||||
location.vpn_config)
|
||||
config_path = self.get_vpn_config_path(location.vpn_config)
|
||||
self.logger.info(f"VPN config: {config_path}")
|
||||
self.logger.info(f"Connection name: {vpntray_name}")
|
||||
|
||||
def get_vpn_ip_address(self, connection_name: str) -> Optional[str]:
|
||||
"""Get the IP address assigned to the VPN connection.
|
||||
# Check if config file exists
|
||||
if not config_path.exists():
|
||||
error_msg = f"VPN config file not found: {config_path}"
|
||||
self.logger.error(error_msg)
|
||||
return False
|
||||
|
||||
Args:
|
||||
connection_name: Name of the VPN connection
|
||||
# Import if not already imported
|
||||
existing_conn = self._get_connection_by_name(vpntray_name)
|
||||
if not existing_conn:
|
||||
self.logger.info(
|
||||
"Importing VPN config for first time...")
|
||||
try:
|
||||
self.import_vpn_config(location)
|
||||
self.logger.info(
|
||||
"VPN config imported successfully")
|
||||
except Exception as import_error:
|
||||
error_msg = f"Failed to import VPN config: {import_error}"
|
||||
self.logger.error(error_msg)
|
||||
return False
|
||||
else:
|
||||
self.logger.info(
|
||||
f"Using existing connection: {existing_conn.status.value}")
|
||||
|
||||
Returns:
|
||||
IP address or None if not connected
|
||||
"""
|
||||
interface = self.get_active_vpn_interface(connection_name)
|
||||
if not interface:
|
||||
return None
|
||||
# Connect with simple command - credentials already set during import
|
||||
self.logger.info("Attempting connection...")
|
||||
|
||||
result = self._run_nmcli(['device', 'show', interface], check=False)
|
||||
# Simple connection command without credential complications
|
||||
connect_args = ['connection', 'up', vpntray_name]
|
||||
self._run_nmcli(connect_args, timeout=60)
|
||||
self.logger.info(f"Connected to {vpntray_name}")
|
||||
|
||||
for line in result.stdout.split('\n'):
|
||||
if 'IP4.ADDRESS' in line and 'IP4.ADDRESS[2]' not in line:
|
||||
# Format is usually "IP4.ADDRESS[1]: 10.0.0.1/24"
|
||||
if ':' in line:
|
||||
addr_part = line.split(':', 1)[1].strip()
|
||||
if '/' in addr_part:
|
||||
return addr_part.split('/')[0]
|
||||
return True
|
||||
|
||||
return None
|
||||
except VPNConnectionError as e:
|
||||
self.logger.error(f"VPN connection failed: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Unexpected error during connection: {e}")
|
||||
return False
|
||||
|
||||
def disconnect_vpn(self, location: Location) -> bool:
|
||||
"""Disconnect VPN for a location."""
|
||||
try:
|
||||
vpntray_name = self._get_vpntray_connection_name(
|
||||
location.vpn_config)
|
||||
self.logger.info(f"Disconnecting from {vpntray_name}...")
|
||||
|
||||
# Check if connection exists
|
||||
existing_conn = self._get_connection_by_name(vpntray_name)
|
||||
if not existing_conn:
|
||||
self.logger.error(
|
||||
f"Connection {vpntray_name} not found")
|
||||
return False
|
||||
|
||||
# Disconnect
|
||||
self._run_nmcli(['connection', 'down', vpntray_name])
|
||||
self.logger.info(f"Disconnected from {vpntray_name}")
|
||||
|
||||
return True
|
||||
|
||||
except VPNConnectionError as e:
|
||||
self.logger.error(f"Failed to disconnect: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Unexpected error during disconnection: {e}")
|
||||
return False
|
||||
|
||||
def get_connection_status(self, location: Location) -> VPNStatus:
|
||||
"""Get connection status for a location."""
|
||||
vpntray_name = self._get_vpntray_connection_name(location.vpn_config)
|
||||
return self._get_connection_status(vpntray_name)
|
||||
|
||||
def remove_vpn_config(self, location: Location) -> bool:
|
||||
"""Remove VPN connection configuration."""
|
||||
try:
|
||||
vpntray_name = self._get_vpntray_connection_name(
|
||||
location.vpn_config)
|
||||
|
||||
# First disconnect if connected
|
||||
try:
|
||||
self._run_nmcli(
|
||||
['connection', 'down', vpntray_name], check=False)
|
||||
except VPNConnectionError:
|
||||
pass # Ignore if already disconnected
|
||||
|
||||
# Remove the connection
|
||||
self._run_nmcli(['connection', 'delete', vpntray_name])
|
||||
self.logger.info(
|
||||
f"Removed VPN configuration {vpntray_name}")
|
||||
|
||||
return True
|
||||
|
||||
except VPNConnectionError as e:
|
||||
self.logger.error(f"Failed to remove config: {e}")
|
||||
return False
|
||||
|
||||
def cleanup_vpntray_connections(self) -> int:
|
||||
"""Remove all VPNTray-managed connections. Returns count removed."""
|
||||
connections = self.list_vpntray_connections()
|
||||
removed_count = 0
|
||||
|
||||
for conn in connections:
|
||||
try:
|
||||
# Disconnect first
|
||||
self._run_nmcli(['connection', 'down', conn.name], check=False)
|
||||
# Remove
|
||||
self._run_nmcli(['connection', 'delete', conn.name])
|
||||
removed_count += 1
|
||||
except VPNConnectionError:
|
||||
pass # Continue with other connections
|
||||
|
||||
if self.logger and removed_count > 0:
|
||||
self.logger.info(
|
||||
f"Cleaned up {removed_count} VPNTray connections")
|
||||
|
||||
return removed_count
|
||||
|
||||
def _configure_credentials(self, connection_name: str, location: Location) -> None:
|
||||
"""Configure VPN credentials directly in the connection."""
|
||||
if not location.vpn_credentials:
|
||||
self.logger.info(
|
||||
f"No credentials provided for {connection_name}")
|
||||
return
|
||||
|
||||
try:
|
||||
# Handle dictionary credentials (username/password)
|
||||
if isinstance(location.vpn_credentials, dict):
|
||||
username = location.vpn_credentials.get('username')
|
||||
password = location.vpn_credentials.get('password')
|
||||
self.logger.info(
|
||||
f"Setting credentials for {connection_name}...")
|
||||
|
||||
# Set username and password with correct nmcli syntax
|
||||
if username:
|
||||
self._run_nmcli([
|
||||
'connection', 'modify', connection_name,
|
||||
'+vpn.data', f'username={username}'
|
||||
])
|
||||
self.logger.info(
|
||||
f"Username configured for {connection_name}")
|
||||
|
||||
if password:
|
||||
self._run_nmcli([
|
||||
'connection', 'modify', connection_name,
|
||||
'+vpn.secrets', f'password={password}'
|
||||
])
|
||||
self.logger.info(
|
||||
f"Password configured for {connection_name}")
|
||||
|
||||
if username and password:
|
||||
self.logger.info(
|
||||
f"Full credentials configured for {connection_name}")
|
||||
elif username or password:
|
||||
self.logger.info(
|
||||
f"Partial credentials configured for {connection_name}")
|
||||
|
||||
except VPNConnectionError as e:
|
||||
self.logger.error(f"Failed to configure credentials: {e}")
|
||||
# Don't fail the whole operation for credential issues
|
||||
|
||||
56
style.css
Normal file
@@ -0,0 +1,56 @@
|
||||
.card {
|
||||
background: @theme_base_color;
|
||||
border-radius: 8px;
|
||||
border: 1px solid @borders;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
padding: 16px;
|
||||
margin: 6px;
|
||||
}
|
||||
|
||||
.location-info {
|
||||
background: linear-gradient(to bottom, alpha(@theme_selected_bg_color, 0.1), alpha(@theme_selected_bg_color, 0.05));
|
||||
border-radius: 8px;
|
||||
border: 1px solid alpha(@theme_selected_bg_color, 0.3);
|
||||
padding: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.log-section {
|
||||
background: @theme_base_color;
|
||||
border-top: 1px solid @borders;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
/* Material Icons font */
|
||||
.material-icons {
|
||||
font-family: "Material Icons";
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* Service button color coding with Material Icons */
|
||||
.service-icon-accessible {
|
||||
font-family: "Material Icons";
|
||||
color: #4caf50;
|
||||
/* Green for accessible */
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.service-icon-inaccessible {
|
||||
font-family: "Material Icons";
|
||||
color: #f44336;
|
||||
/* Red for not accessible */
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.service-icon-accessible:hover {
|
||||
color: #2e7d32;
|
||||
/* Darker green on hover */
|
||||
}
|
||||
|
||||
.service-icon-inaccessible:disabled {
|
||||
color: #ef9a9a;
|
||||
/* Lighter red when disabled */
|
||||
opacity: 0.6;
|
||||
}
|
||||
5
utils/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Utility modules for VPN Manager."""
|
||||
|
||||
from .icon_loader import IconLoader
|
||||
|
||||
__all__ = ['IconLoader']
|
||||
142
utils/icon_loader.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Icon loader utility for host icons with fallback support."""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gtk, GdkPixbuf
|
||||
from models import HostType
|
||||
|
||||
|
||||
class IconLoader:
|
||||
"""Manages loading of host icons with fallback to Material Icons."""
|
||||
|
||||
# Default icon size
|
||||
ICON_SIZE = 20
|
||||
|
||||
# Project root directory
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
ICONS_DIR = PROJECT_ROOT / "assets" / "icons"
|
||||
|
||||
# Material Icons fallback mapping for host types
|
||||
HOST_TYPE_ICONS = {
|
||||
HostType.LINUX: "computer",
|
||||
HostType.WINDOWS: "desktop_windows",
|
||||
HostType.WINDOWS_SERVER: "dns",
|
||||
HostType.PROXMOX: "developer_board",
|
||||
HostType.ESXI: "developer_board",
|
||||
HostType.ROUTER: "router",
|
||||
HostType.SWITCH: "device_hub"
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_host_icon_widget(cls, host, size=None) -> Gtk.Widget:
|
||||
"""Get an icon widget for a host, either custom SVG or Material Icon fallback.
|
||||
|
||||
Args:
|
||||
host: Host object with optional icon field
|
||||
size: Icon size in pixels (default: ICON_SIZE)
|
||||
|
||||
Returns:
|
||||
Gtk.Image if custom icon exists, Gtk.Label with Material Icon otherwise
|
||||
"""
|
||||
if size is None:
|
||||
size = cls.ICON_SIZE
|
||||
|
||||
# Try custom icon first
|
||||
if host.icon:
|
||||
icon_widget = cls._load_custom_icon(host.icon, size)
|
||||
if icon_widget:
|
||||
return icon_widget
|
||||
|
||||
# Fallback to Material Icons based on host type
|
||||
return cls._create_material_icon(host.host_type, size)
|
||||
|
||||
@classmethod
|
||||
def _load_custom_icon(cls, icon_name: str, size: int) -> Gtk.Image:
|
||||
"""Load a custom SVG icon from assets/icons directory.
|
||||
|
||||
Args:
|
||||
icon_name: Name of the icon file without extension (e.g., 'ubuntu')
|
||||
size: Icon size in pixels
|
||||
|
||||
Returns:
|
||||
Gtk.Image if icon exists, None otherwise
|
||||
"""
|
||||
# Try SVG first, then PNG
|
||||
for extension in ['.svg', '.png']:
|
||||
icon_path = cls.ICONS_DIR / f"{icon_name}{extension}"
|
||||
|
||||
if icon_path.exists():
|
||||
try:
|
||||
# Load and scale the icon
|
||||
pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(
|
||||
str(icon_path), size, size
|
||||
)
|
||||
image = Gtk.Image.new_from_pixbuf(pixbuf)
|
||||
return image
|
||||
except Exception as e:
|
||||
print(f"Failed to load icon {icon_path}: {e}")
|
||||
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _create_material_icon(cls, host_type: HostType, size: int) -> Gtk.Label:
|
||||
"""Create a Material Icon label for a host type.
|
||||
|
||||
Args:
|
||||
host_type: HostType enum value
|
||||
size: Icon size in pixels
|
||||
|
||||
Returns:
|
||||
Gtk.Label with Material Icon
|
||||
"""
|
||||
icon_name = cls.HOST_TYPE_ICONS.get(host_type, "computer")
|
||||
|
||||
label = Gtk.Label()
|
||||
label.set_text(icon_name)
|
||||
label.get_style_context().add_class("material-icons")
|
||||
|
||||
# Apply custom CSS for size
|
||||
css = f"""
|
||||
#{label.get_name()} {{
|
||||
font-size: {size}px;
|
||||
}}
|
||||
"""
|
||||
|
||||
return label
|
||||
|
||||
@classmethod
|
||||
def get_service_icon(cls, service_type: str, is_accessible: bool) -> Gtk.Label:
|
||||
"""Get a Material Icon for a service with color coding.
|
||||
|
||||
Args:
|
||||
service_type: Service type string (e.g., 'SSH', 'Web GUI')
|
||||
is_accessible: Whether the service is currently accessible
|
||||
|
||||
Returns:
|
||||
Gtk.Label with colored Material Icon
|
||||
"""
|
||||
# Service type to Material Icons mapping
|
||||
service_icons = {
|
||||
'SSH': 'terminal',
|
||||
'Web GUI': 'language',
|
||||
'RDP': 'desktop_windows',
|
||||
'VNC': 'monitor',
|
||||
'SMB': 'folder_shared',
|
||||
'Database': 'storage',
|
||||
'FTP': 'cloud_upload'
|
||||
}
|
||||
|
||||
icon_name = service_icons.get(service_type, 'settings')
|
||||
|
||||
label = Gtk.Label()
|
||||
label.set_text(icon_name)
|
||||
|
||||
# Apply color based on accessibility
|
||||
if is_accessible:
|
||||
label.get_style_context().add_class("service-icon-accessible")
|
||||
else:
|
||||
label.get_style_context().add_class("service-icon-inaccessible")
|
||||
|
||||
return label
|
||||
@@ -1,4 +1,5 @@
|
||||
from .active_view import ActiveView
|
||||
from .inactive_view import InactiveView
|
||||
from .log_view import LogView, LogLevel
|
||||
|
||||
__all__ = ['ActiveView', 'InactiveView']
|
||||
__all__ = ['ActiveView', 'InactiveView', 'LogView', 'LogLevel']
|
||||
@@ -1,62 +1,65 @@
|
||||
from widgets import ActiveCustomerCard
|
||||
from gi.repository import Gtk
|
||||
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)
|
||||
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)
|
||||
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_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()
|
||||
child.destroy()
|
||||
|
||||
@@ -1,52 +1,54 @@
|
||||
from widgets import InactiveCustomerCard
|
||||
from gi.repository import Gtk
|
||||
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)
|
||||
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)
|
||||
self.content_box.pack_start(
|
||||
customer_card.widget, False, False, 0)
|
||||
else:
|
||||
# Show no results message
|
||||
if search_term:
|
||||
@@ -56,14 +58,14 @@ class InactiveView:
|
||||
)
|
||||
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()
|
||||
child.destroy()
|
||||
|
||||
275
views/log_view.py
Normal file
@@ -0,0 +1,275 @@
|
||||
"""Log view for displaying command output and system logs."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
import time
|
||||
from gi.repository import Gtk, GLib, Pango
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
|
||||
class LogLevel(Enum):
|
||||
"""Log levels for different types of messages."""
|
||||
DEBUG = "DEBUG"
|
||||
INFO = "INFO"
|
||||
WARNING = "WARNING"
|
||||
ERROR = "ERROR"
|
||||
COMMAND = "COMMAND"
|
||||
|
||||
|
||||
class LogView:
|
||||
"""View for displaying logs and command output."""
|
||||
|
||||
def __init__(self):
|
||||
self.widget = self._create_widget()
|
||||
self.max_lines = 1000 # Maximum number of log lines to keep
|
||||
self.auto_scroll = True
|
||||
|
||||
def _create_widget(self):
|
||||
"""Create the main log view widget."""
|
||||
# Main container
|
||||
vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
|
||||
# Header with controls
|
||||
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
header_box.set_margin_start(12)
|
||||
header_box.set_margin_end(12)
|
||||
header_box.set_margin_top(8)
|
||||
header_box.set_margin_bottom(8)
|
||||
vbox.pack_start(header_box, False, False, 0)
|
||||
|
||||
# Log title
|
||||
log_label = Gtk.Label()
|
||||
log_label.set_markup("<b>📋 Command Log</b>")
|
||||
log_label.set_halign(Gtk.Align.START)
|
||||
header_box.pack_start(log_label, False, False, 0)
|
||||
|
||||
# Spacer
|
||||
spacer = Gtk.Box()
|
||||
header_box.pack_start(spacer, True, True, 0)
|
||||
|
||||
# Auto-scroll toggle
|
||||
self.autoscroll_switch = Gtk.Switch()
|
||||
self.autoscroll_switch.set_active(True)
|
||||
self.autoscroll_switch.connect(
|
||||
"notify::active", self._on_autoscroll_toggle)
|
||||
header_box.pack_start(self.autoscroll_switch, False, False, 0)
|
||||
|
||||
autoscroll_label = Gtk.Label()
|
||||
autoscroll_label.set_text("Auto-scroll")
|
||||
autoscroll_label.set_margin_start(4)
|
||||
header_box.pack_start(autoscroll_label, False, False, 0)
|
||||
|
||||
# Clear button
|
||||
clear_btn = Gtk.Button(label="Clear")
|
||||
clear_btn.connect("clicked", self._on_clear_clicked)
|
||||
header_box.pack_start(clear_btn, False, False, 0)
|
||||
|
||||
# Separator
|
||||
separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
vbox.pack_start(separator, False, False, 0)
|
||||
|
||||
# Scrolled window for log content
|
||||
scrolled = Gtk.ScrolledWindow()
|
||||
scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
|
||||
scrolled.set_min_content_height(150)
|
||||
scrolled.set_max_content_height(400)
|
||||
vbox.pack_start(scrolled, True, True, 0)
|
||||
|
||||
# Text view for log content
|
||||
self.text_view = Gtk.TextView()
|
||||
self.text_view.set_editable(False)
|
||||
self.text_view.set_cursor_visible(False)
|
||||
self.text_view.set_wrap_mode(Gtk.WrapMode.WORD)
|
||||
|
||||
# Set monospace font
|
||||
font_desc = Pango.FontDescription("monospace 9")
|
||||
self.text_view.modify_font(font_desc)
|
||||
|
||||
scrolled.add(self.text_view)
|
||||
|
||||
# Get text buffer and create tags for different log levels
|
||||
self.text_buffer = self.text_view.get_buffer()
|
||||
self._create_text_tags()
|
||||
|
||||
# Store reference to scrolled window for auto-scrolling
|
||||
self.scrolled_window = scrolled
|
||||
|
||||
return vbox
|
||||
|
||||
def _create_text_tags(self):
|
||||
"""Create text tags for different log levels."""
|
||||
# Command tag (bold, blue)
|
||||
command_tag = self.text_buffer.create_tag("command")
|
||||
command_tag.set_property("weight", Pango.Weight.BOLD)
|
||||
command_tag.set_property("foreground", "#0066cc")
|
||||
|
||||
# Info tag (default)
|
||||
info_tag = self.text_buffer.create_tag("info")
|
||||
|
||||
# Warning tag (orange)
|
||||
warning_tag = self.text_buffer.create_tag("warning")
|
||||
warning_tag.set_property("foreground", "#ff8800")
|
||||
|
||||
# Error tag (red, bold)
|
||||
error_tag = self.text_buffer.create_tag("error")
|
||||
error_tag.set_property("foreground", "#cc0000")
|
||||
error_tag.set_property("weight", Pango.Weight.BOLD)
|
||||
|
||||
# Debug tag (gray)
|
||||
debug_tag = self.text_buffer.create_tag("debug")
|
||||
debug_tag.set_property("foreground", "#666666")
|
||||
|
||||
# Timestamp tag (small, gray)
|
||||
timestamp_tag = self.text_buffer.create_tag("timestamp")
|
||||
timestamp_tag.set_property("foreground", "#888888")
|
||||
timestamp_tag.set_property("size", 8 * Pango.SCALE)
|
||||
|
||||
def _on_autoscroll_toggle(self, switch, gparam):
|
||||
"""Handle auto-scroll toggle."""
|
||||
self.auto_scroll = switch.get_active()
|
||||
|
||||
def _on_clear_clicked(self, button):
|
||||
"""Clear the log content."""
|
||||
self.text_buffer.set_text("")
|
||||
|
||||
def _auto_scroll_to_bottom(self):
|
||||
"""Scroll to bottom if auto-scroll is enabled."""
|
||||
if not self.auto_scroll:
|
||||
return
|
||||
|
||||
# Get the end iterator
|
||||
end_iter = self.text_buffer.get_end_iter()
|
||||
|
||||
# Create a mark at the end
|
||||
mark = self.text_buffer.get_insert()
|
||||
self.text_buffer.place_cursor(end_iter)
|
||||
|
||||
# Scroll to the mark
|
||||
self.text_view.scroll_mark_onscreen(mark)
|
||||
|
||||
def _get_timestamp(self) -> str:
|
||||
"""Get current timestamp string."""
|
||||
return time.strftime("%H:%M:%S")
|
||||
|
||||
def _trim_log_if_needed(self):
|
||||
"""Trim log to max_lines if exceeded."""
|
||||
line_count = self.text_buffer.get_line_count()
|
||||
if line_count <= self.max_lines:
|
||||
return
|
||||
|
||||
# Calculate how many lines to remove (keep some buffer)
|
||||
lines_to_remove = line_count - (self.max_lines - 100)
|
||||
|
||||
# Get iterator at start
|
||||
start_iter = self.text_buffer.get_start_iter()
|
||||
|
||||
# Move to the line we want to keep
|
||||
end_iter = self.text_buffer.get_iter_at_line(lines_to_remove)
|
||||
|
||||
# Delete the old lines
|
||||
self.text_buffer.delete(start_iter, end_iter)
|
||||
|
||||
def log_message(self, message: str, level: LogLevel = LogLevel.INFO,
|
||||
command: Optional[str] = None):
|
||||
"""Add a log message to the view.
|
||||
|
||||
Args:
|
||||
message: The message to log
|
||||
level: The log level
|
||||
command: Optional command that generated this message
|
||||
"""
|
||||
# Ensure we're on the main thread
|
||||
GLib.idle_add(self._add_log_message, message, level, command)
|
||||
|
||||
def _add_log_message(self, message: str, level: LogLevel, command: Optional[str]):
|
||||
"""Add log message to buffer (main thread only)."""
|
||||
timestamp = self._get_timestamp()
|
||||
|
||||
# Get end iterator
|
||||
end_iter = self.text_buffer.get_end_iter()
|
||||
|
||||
# Add timestamp
|
||||
self.text_buffer.insert_with_tags_by_name(
|
||||
end_iter, f"[{timestamp}] ", "timestamp"
|
||||
)
|
||||
|
||||
# Add command if provided
|
||||
if command:
|
||||
end_iter = self.text_buffer.get_end_iter()
|
||||
self.text_buffer.insert_with_tags_by_name(
|
||||
end_iter, f"$ {command}\n", "command"
|
||||
)
|
||||
|
||||
# Add the message with appropriate tag
|
||||
end_iter = self.text_buffer.get_end_iter()
|
||||
tag_name = level.value.lower()
|
||||
self.text_buffer.insert_with_tags_by_name(
|
||||
end_iter, f"{message}\n", tag_name
|
||||
)
|
||||
|
||||
# Trim log if needed
|
||||
self._trim_log_if_needed()
|
||||
|
||||
# Auto-scroll to bottom
|
||||
self._auto_scroll_to_bottom()
|
||||
|
||||
return False # Remove from idle queue
|
||||
|
||||
def log_command(self, command: str, output: str = "", error: str = "",
|
||||
return_code: int = 0):
|
||||
"""Log a command execution with its output.
|
||||
|
||||
Args:
|
||||
command: The command that was executed
|
||||
output: Standard output from the command
|
||||
error: Standard error from the command
|
||||
return_code: Command return code
|
||||
"""
|
||||
# Log the command
|
||||
self.log_message("", LogLevel.COMMAND, command)
|
||||
|
||||
# Log output if present
|
||||
if output.strip():
|
||||
for line in output.strip().split('\n'):
|
||||
self.log_message(line, LogLevel.INFO)
|
||||
|
||||
# Log error if present
|
||||
if error.strip():
|
||||
for line in error.strip().split('\n'):
|
||||
self.log_message(f"ERROR: {line}", LogLevel.ERROR)
|
||||
|
||||
# Log return code if non-zero
|
||||
if return_code != 0:
|
||||
self.log_message(
|
||||
f"Command exited with code: {return_code}", LogLevel.ERROR)
|
||||
elif return_code == 0 and (output.strip() or error.strip()):
|
||||
self.log_message("Command completed successfully", LogLevel.INFO)
|
||||
|
||||
def log_info(self, message: str):
|
||||
"""Log an info message."""
|
||||
self.log_message(message, LogLevel.INFO)
|
||||
|
||||
def log_warning(self, message: str):
|
||||
"""Log a warning message."""
|
||||
self.log_message(message, LogLevel.WARNING)
|
||||
|
||||
def log_error(self, message: str):
|
||||
"""Log an error message."""
|
||||
self.log_message(message, LogLevel.ERROR)
|
||||
|
||||
def log_debug(self, message: str):
|
||||
"""Log a debug message."""
|
||||
self.log_message(message, LogLevel.DEBUG)
|
||||
|
||||
def log_success(self, message: str):
|
||||
"""Log a success message."""
|
||||
self.log_message(f"✓ {message}", LogLevel.INFO)
|
||||
|
||||
def set_visible(self, visible: bool):
|
||||
"""Set visibility of the entire view."""
|
||||
self.widget.set_visible(visible)
|
||||
|
||||
def clear(self):
|
||||
"""Clear all log content."""
|
||||
self._on_clear_clicked(None)
|
||||
@@ -1,11 +1,12 @@
|
||||
from .host_item import HostItem
|
||||
from .location_card import ActiveLocationCard, InactiveLocationCard
|
||||
from .customer_card import ActiveCustomerCard, InactiveCustomerCard
|
||||
from .active_customer_card import ActiveCustomerCard
|
||||
from .inactive_customer_card import InactiveCustomerCard
|
||||
|
||||
__all__ = [
|
||||
'HostItem',
|
||||
'ActiveLocationCard',
|
||||
'ActiveLocationCard',
|
||||
'InactiveLocationCard',
|
||||
'ActiveCustomerCard',
|
||||
'InactiveCustomerCard'
|
||||
]
|
||||
]
|
||||
|
||||
399
widgets/active_customer_card.py
Normal file
@@ -0,0 +1,399 @@
|
||||
from utils import IconLoader
|
||||
from gi.repository import Gtk
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
|
||||
def escape_markup(text: str) -> str:
|
||||
"""Escape special characters for Pango markup."""
|
||||
return text.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
|
||||
class ActiveCustomerCard:
|
||||
def __init__(self, customer, callbacks):
|
||||
self.customer = customer
|
||||
self.callbacks = callbacks
|
||||
self.expanded = True # Start expanded by default
|
||||
self.location_expanded = {} # Track expansion state of each location
|
||||
# Initialize all locations as expanded
|
||||
for location in self.customer.locations:
|
||||
self.location_expanded[location.name] = True
|
||||
self.widget = self._create_widget()
|
||||
|
||||
def _create_widget(self):
|
||||
# Customer card container
|
||||
customer_frame = Gtk.Frame()
|
||||
customer_frame.get_style_context().add_class("card")
|
||||
customer_frame.set_shadow_type(Gtk.ShadowType.NONE)
|
||||
|
||||
customer_vbox = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=4)
|
||||
customer_frame.add(customer_vbox)
|
||||
|
||||
# Customer header row
|
||||
customer_row = self._create_customer_header()
|
||||
customer_vbox.pack_start(customer_row, False, False, 0)
|
||||
|
||||
# Content container (locations)
|
||||
self.content_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=6)
|
||||
self.content_box.set_margin_start(8)
|
||||
self.content_box.set_margin_end(8)
|
||||
self.content_box.set_margin_bottom(8)
|
||||
customer_vbox.pack_start(self.content_box, False, False, 0)
|
||||
|
||||
# Add location cards
|
||||
for location in self.customer.locations:
|
||||
location_card = self._create_location_card(location)
|
||||
self.content_box.pack_start(location_card, False, False, 0)
|
||||
|
||||
return customer_frame
|
||||
|
||||
def _create_customer_header(self):
|
||||
"""Create the customer header row."""
|
||||
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
row.set_margin_start(4)
|
||||
row.set_margin_end(4)
|
||||
row.set_margin_top(4)
|
||||
row.set_margin_bottom(2)
|
||||
|
||||
# Expand/collapse arrow
|
||||
self.expand_button = Gtk.Button()
|
||||
self.expand_button.set_relief(Gtk.ReliefStyle.NONE)
|
||||
self.expand_button.set_can_focus(False)
|
||||
self.expand_button.set_size_request(20, 20)
|
||||
self._update_expand_button()
|
||||
self.expand_button.connect("clicked", self._on_expand_toggle)
|
||||
row.pack_start(self.expand_button, False, False, 0)
|
||||
|
||||
# Customer name
|
||||
customer_label = Gtk.Label()
|
||||
escaped_name = escape_markup(self.customer.name)
|
||||
customer_label.set_markup(f"<b>{escaped_name}</b>")
|
||||
customer_label.set_halign(Gtk.Align.START)
|
||||
row.pack_start(customer_label, True, True, 0)
|
||||
|
||||
# Customer service icons (right side)
|
||||
self._add_customer_service_icons(row)
|
||||
|
||||
return row
|
||||
|
||||
def _create_location_card(self, location):
|
||||
"""Create a location card (subcard within customer card)."""
|
||||
# Location subcard
|
||||
location_frame = Gtk.Frame()
|
||||
location_frame.get_style_context().add_class("card")
|
||||
location_frame.set_shadow_type(Gtk.ShadowType.NONE)
|
||||
|
||||
location_vbox = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||
location_frame.add(location_vbox)
|
||||
|
||||
# Location header row
|
||||
location_row = self._create_location_header(location)
|
||||
location_vbox.pack_start(location_row, False, False, 0)
|
||||
|
||||
# Hosts container (collapsible)
|
||||
location_key = location.name
|
||||
hosts_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||
hosts_box.set_margin_start(8)
|
||||
hosts_box.set_margin_end(4)
|
||||
hosts_box.set_margin_bottom(4)
|
||||
|
||||
# Add hosts for this location
|
||||
if location.hosts:
|
||||
for host in location.hosts:
|
||||
host_row = self._create_host_row(host, location)
|
||||
hosts_box.pack_start(host_row, False, False, 0)
|
||||
|
||||
# Add sub-hosts (VMs)
|
||||
if host.sub_hosts:
|
||||
for vm in host.sub_hosts:
|
||||
vm_row = self._create_host_row(
|
||||
vm, location, is_vm=True)
|
||||
hosts_box.pack_start(vm_row, False, False, 0)
|
||||
|
||||
location_vbox.pack_start(hosts_box, False, False, 0)
|
||||
|
||||
# Store reference to hosts_box for expand/collapse
|
||||
setattr(location_frame, 'hosts_box', hosts_box)
|
||||
setattr(location_frame, 'location', location)
|
||||
|
||||
# Set initial visibility
|
||||
expanded = self.location_expanded.get(location_key, True)
|
||||
hosts_box.set_visible(expanded)
|
||||
|
||||
return location_frame
|
||||
|
||||
def _create_location_header(self, location):
|
||||
"""Create the location header row."""
|
||||
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
row.set_margin_start(4)
|
||||
row.set_margin_end(4)
|
||||
row.set_margin_top(4)
|
||||
row.set_margin_bottom(2)
|
||||
|
||||
# Location expand/collapse arrow
|
||||
expand_btn = Gtk.Button()
|
||||
expand_btn.set_relief(Gtk.ReliefStyle.NONE)
|
||||
expand_btn.set_can_focus(False)
|
||||
expand_btn.set_size_request(20, 20)
|
||||
|
||||
# Set initial arrow direction
|
||||
expanded = self.location_expanded.get(location.name, True)
|
||||
expand_btn.set_label("▼" if expanded else "▶")
|
||||
|
||||
# Connect to toggle function
|
||||
expand_btn.connect(
|
||||
"clicked", lambda btn: self._toggle_location_expansion(location, btn))
|
||||
row.pack_start(expand_btn, False, False, 0)
|
||||
|
||||
# Location info
|
||||
location_info = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=0)
|
||||
|
||||
# Location name
|
||||
name_label = Gtk.Label()
|
||||
escaped_location_name = escape_markup(location.name)
|
||||
name_label.set_markup(f"<b>{escaped_location_name}</b>")
|
||||
name_label.set_halign(Gtk.Align.START)
|
||||
location_info.pack_start(name_label, False, False, 0)
|
||||
|
||||
# External addresses (small text)
|
||||
if location.external_addresses:
|
||||
addr_text = ", ".join(
|
||||
location.external_addresses[:2]) # Show first 2
|
||||
if len(location.external_addresses) > 2:
|
||||
addr_text += f" (+{len(location.external_addresses) - 2} more)"
|
||||
addr_label = Gtk.Label()
|
||||
addr_label.set_markup(f"<small><i>{addr_text}</i></small>")
|
||||
addr_label.set_halign(Gtk.Align.START)
|
||||
location_info.pack_start(addr_label, False, False, 0)
|
||||
|
||||
row.pack_start(location_info, True, True, 0)
|
||||
|
||||
# VPN Status
|
||||
status_label = Gtk.Label()
|
||||
if location.connected:
|
||||
status_label.set_markup(
|
||||
"<span color='#4caf50'><b>Connected</b></span>")
|
||||
else:
|
||||
status_label.set_markup(
|
||||
"<span color='#999'><b>Disconnected</b></span>")
|
||||
row.pack_start(status_label, False, False, 0)
|
||||
|
||||
# Action icons
|
||||
self._add_location_action_icons(row, location)
|
||||
|
||||
return row
|
||||
|
||||
def _create_host_row(self, host, location, is_vm=False):
|
||||
"""Create a host row with aligned IP addresses."""
|
||||
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
margin = 16 if not is_vm else 32 # VMs are more indented
|
||||
row.set_margin_start(margin)
|
||||
row.set_margin_end(4)
|
||||
row.set_margin_top(1)
|
||||
row.set_margin_bottom(1)
|
||||
|
||||
# Host icon - custom or fallback to Material Icons
|
||||
icon_widget = IconLoader.get_host_icon_widget(host, size=20)
|
||||
icon_container = Gtk.Box()
|
||||
icon_container.set_size_request(24, 24) # Fixed size for alignment
|
||||
icon_container.set_center_widget(icon_widget)
|
||||
row.pack_start(icon_container, False, False, 0)
|
||||
|
||||
# Host name (fixed width for alignment)
|
||||
escaped_host_name = escape_markup(host.name)
|
||||
name_markup = f"<i>{escaped_host_name}</i>" if is_vm else f"<b>{escaped_host_name}</b>"
|
||||
name_label = Gtk.Label()
|
||||
name_label.set_markup(name_markup)
|
||||
name_label.set_halign(Gtk.Align.START)
|
||||
name_label.set_size_request(150, -1) # Fixed width to align IPs
|
||||
row.pack_start(name_label, False, False, 0)
|
||||
|
||||
# IP address (aligned in middle)
|
||||
ip_label = Gtk.Label()
|
||||
ip_label.set_markup(f"<small>{host.get_ip_display()}</small>")
|
||||
ip_label.set_size_request(120, -1) # Fixed width for alignment
|
||||
ip_label.set_halign(Gtk.Align.CENTER)
|
||||
ip_label.set_tooltip_text(", ".join(host.get_all_ips()) if len(
|
||||
host.ip_addresses) > 1 else host.get_primary_ip())
|
||||
row.pack_start(ip_label, False, False, 0)
|
||||
|
||||
# Spacer to push service icons to the right
|
||||
spacer = Gtk.Box()
|
||||
row.pack_start(spacer, True, True, 0)
|
||||
|
||||
# Service action icons
|
||||
self._add_host_service_icons(row, host, location)
|
||||
|
||||
return row
|
||||
|
||||
def _add_customer_service_icons(self, row):
|
||||
"""Add customer service icons to the right side."""
|
||||
# Service type to icon mapping
|
||||
service_icons = {
|
||||
'Email & Office': '📧', # O365
|
||||
'Phone System': '📞', # PBX
|
||||
'CRM': '👥', # Salesforce
|
||||
'Email': '📧',
|
||||
'Office': '📄',
|
||||
}
|
||||
|
||||
# Add icons for each service
|
||||
for service in self.customer.services[:4]: # Limit to 4 icons
|
||||
icon = service_icons.get(service.service_type, '🌐')
|
||||
|
||||
btn = Gtk.Button()
|
||||
btn.set_label(icon)
|
||||
btn.set_relief(Gtk.ReliefStyle.NONE)
|
||||
btn.set_can_focus(False)
|
||||
btn.set_size_request(24, 24)
|
||||
btn.set_tooltip_text(f"Open {service.name}")
|
||||
btn.connect("clicked", lambda b,
|
||||
s=service: self.callbacks['open_customer_service'](s))
|
||||
row.pack_start(btn, False, False, 0)
|
||||
|
||||
# Menu button (always last)
|
||||
menu_btn = self._create_menu_button()
|
||||
row.pack_start(menu_btn, False, False, 0)
|
||||
|
||||
def _add_location_action_icons(self, row, location):
|
||||
"""Add location action icons."""
|
||||
# Connection toggle
|
||||
connect_icon = "🔌" if not location.connected else "🔓"
|
||||
connect_btn = Gtk.Button()
|
||||
connect_btn.set_label(connect_icon)
|
||||
connect_btn.set_relief(Gtk.ReliefStyle.NONE)
|
||||
connect_btn.set_can_focus(False)
|
||||
connect_btn.set_size_request(24, 24)
|
||||
tooltip = "Connect to VPN" if not location.connected else "Disconnect VPN"
|
||||
connect_btn.set_tooltip_text(tooltip)
|
||||
connect_btn.connect(
|
||||
"clicked", lambda b: self.callbacks['toggle_connection'](location))
|
||||
row.pack_start(connect_btn, False, False, 0)
|
||||
|
||||
# Refresh/reload
|
||||
refresh_btn = Gtk.Button()
|
||||
refresh_btn.set_label("🔄")
|
||||
refresh_btn.set_relief(Gtk.ReliefStyle.NONE)
|
||||
refresh_btn.set_can_focus(False)
|
||||
refresh_btn.set_size_request(24, 24)
|
||||
refresh_btn.set_tooltip_text("Refresh connection")
|
||||
row.pack_start(refresh_btn, False, False, 0)
|
||||
|
||||
# Menu
|
||||
menu_btn = self._create_menu_button()
|
||||
row.pack_start(menu_btn, False, False, 0)
|
||||
|
||||
def _add_host_service_icons(self, row, host, location):
|
||||
"""Add host service icons with reachability check."""
|
||||
# Service type to Material Icons mapping
|
||||
# Icon names from: https://fonts.google.com/icons
|
||||
service_icons = {
|
||||
'SSH': 'terminal', # Terminal icon for SSH
|
||||
'Web GUI': 'language', # Globe icon for web
|
||||
'RDP': 'desktop_windows', # Desktop icon for RDP
|
||||
'VNC': 'monitor', # Monitor icon for VNC
|
||||
'SMB': 'folder_shared', # Shared folder for SMB
|
||||
'Database': 'storage', # Database/storage icon
|
||||
'FTP': 'cloud_upload' # Upload icon for FTP
|
||||
}
|
||||
|
||||
# Add icons for services
|
||||
for service in host.services[:3]: # Limit to 3 service icons
|
||||
# Default to settings icon
|
||||
icon = service_icons.get(service.service_type.value, 'settings')
|
||||
|
||||
# Check if service is reachable
|
||||
is_reachable = location.is_service_reachable(host, service)
|
||||
is_external = location.get_external_url_for_service(
|
||||
host, service) is not None
|
||||
|
||||
btn = Gtk.Button()
|
||||
btn.set_label(icon) # Material Icons uses ligatures
|
||||
btn.set_relief(Gtk.ReliefStyle.NONE)
|
||||
btn.set_can_focus(False)
|
||||
btn.set_size_request(24, 24)
|
||||
|
||||
# Apply color styling based on reachability
|
||||
if is_reachable:
|
||||
# Green for accessible
|
||||
btn.get_style_context().add_class("service-icon-accessible")
|
||||
if is_external and not location.connected:
|
||||
external_url = location.get_external_url_for_service(
|
||||
host, service)
|
||||
btn.set_tooltip_text(
|
||||
f"{service.service_type.value}: {service.name}\nExternal: {external_url}")
|
||||
else:
|
||||
btn.set_tooltip_text(
|
||||
f"{service.service_type.value}: {service.name}")
|
||||
else:
|
||||
# Red for not accessible
|
||||
btn.get_style_context().add_class("service-icon-inaccessible")
|
||||
btn.set_tooltip_text(
|
||||
f"{service.service_type.value}: {service.name}\nNot reachable (VPN disconnected)")
|
||||
|
||||
# Enable/disable based on reachability
|
||||
btn.set_sensitive(is_reachable)
|
||||
|
||||
# Connect click handler only if reachable
|
||||
if is_reachable:
|
||||
btn.connect("clicked", lambda b,
|
||||
s=service: self.callbacks['open_service'](s))
|
||||
|
||||
row.pack_start(btn, False, False, 0)
|
||||
|
||||
# Menu button
|
||||
menu_btn = self._create_menu_button()
|
||||
row.pack_start(menu_btn, False, False, 0)
|
||||
|
||||
def _create_menu_button(self):
|
||||
"""Create a menu button with empty popup."""
|
||||
menu_btn = Gtk.MenuButton()
|
||||
menu_btn.set_label("⋯") # Three dots menu
|
||||
menu_btn.set_relief(Gtk.ReliefStyle.NONE)
|
||||
menu_btn.set_can_focus(False)
|
||||
menu_btn.set_size_request(24, 24)
|
||||
|
||||
# Create empty menu for now
|
||||
menu = Gtk.Menu()
|
||||
placeholder_item = Gtk.MenuItem(label="(Empty menu)")
|
||||
placeholder_item.set_sensitive(False)
|
||||
menu.append(placeholder_item)
|
||||
menu.show_all()
|
||||
|
||||
menu_btn.set_popup(menu)
|
||||
return menu_btn
|
||||
|
||||
def _update_expand_button(self):
|
||||
"""Update the expand button arrow direction."""
|
||||
if self.expanded:
|
||||
self.expand_button.set_label("▼")
|
||||
else:
|
||||
self.expand_button.set_label("▶")
|
||||
|
||||
def _toggle_location_expansion(self, location, button):
|
||||
"""Toggle the expansion state of a specific location."""
|
||||
location_key = location.name
|
||||
current_state = self.location_expanded.get(location_key, True)
|
||||
new_state = not current_state
|
||||
self.location_expanded[location_key] = new_state
|
||||
|
||||
# Update button arrow
|
||||
button.set_label("▼" if new_state else "▶")
|
||||
|
||||
# Find the location card and toggle its hosts box visibility
|
||||
for widget in self.content_box.get_children():
|
||||
if hasattr(widget, 'location') and widget.location.name == location_key:
|
||||
hosts_box = getattr(widget, 'hosts_box', None)
|
||||
if hosts_box:
|
||||
hosts_box.set_visible(new_state)
|
||||
break
|
||||
|
||||
def _on_expand_toggle(self, button):
|
||||
"""Toggle the expanded state."""
|
||||
self.expanded = not self.expanded
|
||||
self._update_expand_button()
|
||||
self.content_box.set_visible(self.expanded)
|
||||
@@ -1,114 +0,0 @@
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gtk
|
||||
from .location_card import ActiveLocationCard, InactiveLocationCard
|
||||
|
||||
|
||||
class ActiveCustomerCard:
|
||||
def __init__(self, customer, callbacks):
|
||||
self.customer = customer
|
||||
self.callbacks = callbacks
|
||||
self.widget = self._create_widget()
|
||||
|
||||
def _create_widget(self):
|
||||
# GNOME-style card container
|
||||
card_frame = Gtk.Frame()
|
||||
card_frame.get_style_context().add_class("card")
|
||||
card_frame.set_shadow_type(Gtk.ShadowType.NONE) # Shadow handled by CSS
|
||||
|
||||
card_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||
card_frame.add(card_vbox)
|
||||
|
||||
# Customer header
|
||||
customer_label = Gtk.Label()
|
||||
customer_label.set_markup(f"<b><big>🏢 {self.customer.name}</big></b>")
|
||||
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
|
||||
separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
separator.set_margin_top(8)
|
||||
separator.set_margin_bottom(8)
|
||||
card_vbox.pack_start(separator, False, False, 0)
|
||||
|
||||
location_card = ActiveLocationCard(location, self.customer.name, self.callbacks)
|
||||
card_vbox.pack_start(location_card.widget, False, False, 0)
|
||||
|
||||
return card_frame
|
||||
|
||||
|
||||
class InactiveCustomerCard:
|
||||
def __init__(self, customer, callbacks):
|
||||
self.customer = customer
|
||||
self.callbacks = callbacks
|
||||
self.widget = self._create_widget()
|
||||
|
||||
def _create_widget(self):
|
||||
# GNOME-style card container
|
||||
card_frame = Gtk.Frame()
|
||||
card_frame.get_style_context().add_class("card")
|
||||
card_frame.set_shadow_type(Gtk.ShadowType.NONE) # Shadow handled by CSS
|
||||
|
||||
card_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||
card_frame.add(card_vbox)
|
||||
|
||||
# Customer header - muted
|
||||
customer_label = Gtk.Label()
|
||||
customer_label.set_markup(f"<span alpha='60%'><b><big>🏢 {self.customer.name}</big></b></span>")
|
||||
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
|
||||
separator = Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
separator.set_margin_top(8)
|
||||
separator.set_margin_bottom(8)
|
||||
card_vbox.pack_start(separator, False, False, 0)
|
||||
|
||||
location_card = InactiveLocationCard(location, self.customer.name, self.callbacks)
|
||||
card_vbox.pack_start(location_card.widget, False, False, 0)
|
||||
|
||||
return card_frame
|
||||
@@ -2,11 +2,18 @@ import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gtk
|
||||
from models import ServiceType, HostType
|
||||
from utils import IconLoader
|
||||
|
||||
|
||||
def escape_markup(text: str) -> str:
|
||||
"""Escape special characters for Pango markup."""
|
||||
return text.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
|
||||
class HostItem:
|
||||
def __init__(self, host, open_service_callback):
|
||||
def __init__(self, host, location, open_service_callback):
|
||||
self.host = host
|
||||
self.location = location
|
||||
self.open_service_callback = open_service_callback
|
||||
self.widget = self._create_widget()
|
||||
|
||||
@@ -18,20 +25,12 @@ class HostItem:
|
||||
host_header = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
host_box.pack_start(host_header, False, False, 0)
|
||||
|
||||
# Host type icon
|
||||
type_icons = {
|
||||
HostType.LINUX: "🐧",
|
||||
HostType.WINDOWS: "🪟",
|
||||
HostType.WINDOWS_SERVER: "🖥️",
|
||||
HostType.PROXMOX: "📦",
|
||||
HostType.ESXI: "📦",
|
||||
HostType.ROUTER: "🌐",
|
||||
HostType.SWITCH: "🔗"
|
||||
}
|
||||
icon = type_icons.get(self.host.host_type, "💻")
|
||||
|
||||
icon_label = Gtk.Label(label=icon)
|
||||
host_header.pack_start(icon_label, False, False, 0)
|
||||
# Host icon - custom or fallback to Material Icons
|
||||
icon_widget = IconLoader.get_host_icon_widget(self.host, size=24)
|
||||
icon_container = Gtk.Box()
|
||||
icon_container.set_size_request(32, 24) # Fixed size
|
||||
icon_container.set_center_widget(icon_widget)
|
||||
host_header.pack_start(icon_container, False, False, 0)
|
||||
|
||||
# Host details - compact single line
|
||||
details_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1)
|
||||
@@ -39,8 +38,14 @@ class HostItem:
|
||||
|
||||
# Host name with IP inline
|
||||
name_label = Gtk.Label()
|
||||
name_label.set_markup(f"<b>{self.host.name}</b> <small>({self.host.host_type.value}) - <tt>{self.host.ip_address}</tt></small>")
|
||||
ip_display = self.host.get_ip_display()
|
||||
escaped_host_name = escape_markup(self.host.name)
|
||||
escaped_host_type = escape_markup(self.host.host_type.value)
|
||||
escaped_ip_display = escape_markup(ip_display)
|
||||
name_label.set_markup(f"<b>{escaped_host_name}</b> <small>({escaped_host_type}) - <tt>{escaped_ip_display}</tt></small>")
|
||||
name_label.set_halign(Gtk.Align.START)
|
||||
if len(self.host.ip_addresses) > 1:
|
||||
name_label.set_tooltip_text(f"All IPs: {', '.join(self.host.get_all_ips())}")
|
||||
details_vbox.pack_start(name_label, False, False, 0)
|
||||
|
||||
# Services section - compact button row
|
||||
@@ -52,9 +57,35 @@ class HostItem:
|
||||
|
||||
for service in self.host.services:
|
||||
if service.service_type in [ServiceType.WEB_GUI, ServiceType.SSH, ServiceType.RDP]: # Only show launchable services
|
||||
# Check if service is reachable
|
||||
is_reachable = self.location.is_service_reachable(self.host, service)
|
||||
is_external = self.location.get_external_url_for_service(self.host, service) is not None
|
||||
|
||||
service_btn = Gtk.Button(label=service.service_type.value)
|
||||
service_btn.get_style_context().add_class("suggested-action")
|
||||
service_btn.connect("clicked", lambda btn, s=service: self._on_service_clicked(s))
|
||||
|
||||
# Apply color-based styling
|
||||
if is_reachable:
|
||||
# Green styling for accessible services
|
||||
service_btn.get_style_context().add_class("suggested-action")
|
||||
service_btn.set_name("service-btn-accessible")
|
||||
if is_external and not self.location.connected:
|
||||
external_url = self.location.get_external_url_for_service(self.host, service)
|
||||
service_btn.set_tooltip_text(f"Open {service.name}\nExternal: {external_url}")
|
||||
else:
|
||||
service_btn.set_tooltip_text(f"Open {service.name}")
|
||||
else:
|
||||
# Red styling for inaccessible services
|
||||
service_btn.get_style_context().add_class("destructive-action")
|
||||
service_btn.set_name("service-btn-inaccessible")
|
||||
service_btn.set_tooltip_text(f"{service.name} - Not reachable (VPN disconnected)")
|
||||
|
||||
# Enable/disable based on reachability
|
||||
service_btn.set_sensitive(is_reachable)
|
||||
|
||||
# Connect handler only if reachable
|
||||
if is_reachable:
|
||||
service_btn.connect("clicked", lambda btn, s=service: self._on_service_clicked(s))
|
||||
|
||||
services_box.pack_start(service_btn, False, False, 0)
|
||||
|
||||
# Sub-hosts (VMs) section
|
||||
@@ -71,7 +102,7 @@ class HostItem:
|
||||
host_box.pack_start(subhosts_box, False, False, 0)
|
||||
|
||||
for subhost in self.host.sub_hosts:
|
||||
subhost_item = HostItem(subhost, self.open_service_callback)
|
||||
subhost_item = HostItem(subhost, self.location, self.open_service_callback)
|
||||
subhosts_box.pack_start(subhost_item.widget, False, False, 0)
|
||||
|
||||
return host_box
|
||||
|
||||
118
widgets/inactive_customer_card.py
Normal file
@@ -0,0 +1,118 @@
|
||||
from .location_card import InactiveLocationCard
|
||||
from gi.repository import Gtk
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
|
||||
|
||||
def escape_markup(text: str) -> str:
|
||||
"""Escape special characters for Pango markup."""
|
||||
return text.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
|
||||
class InactiveCustomerCard:
|
||||
def __init__(self, customer, callbacks):
|
||||
self.customer = customer
|
||||
self.callbacks = callbacks
|
||||
self.expanded = False # Start collapsed by default for inactive
|
||||
self.widget = self._create_widget()
|
||||
|
||||
def _create_widget(self):
|
||||
# GNOME-style card container
|
||||
card_frame = Gtk.Frame()
|
||||
card_frame.get_style_context().add_class("card")
|
||||
card_frame.set_shadow_type(
|
||||
Gtk.ShadowType.NONE) # Shadow handled by CSS
|
||||
|
||||
card_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||
card_frame.add(card_vbox)
|
||||
|
||||
# Customer header with expand/collapse button - muted
|
||||
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
card_vbox.pack_start(header_box, False, False, 0)
|
||||
|
||||
# Expand/collapse arrow button
|
||||
self.expand_button = Gtk.Button()
|
||||
self.expand_button.set_relief(Gtk.ReliefStyle.NONE)
|
||||
self.expand_button.set_can_focus(False)
|
||||
self._update_expand_button()
|
||||
self.expand_button.connect("clicked", self._on_expand_toggle)
|
||||
header_box.pack_start(self.expand_button, False, False, 0)
|
||||
|
||||
# Customer name - muted
|
||||
customer_label = Gtk.Label()
|
||||
escaped_name = escape_markup(self.customer.name)
|
||||
customer_label.set_markup(
|
||||
f"<span alpha='60%'><b><big>🏢 {escaped_name}</big></b></span>")
|
||||
customer_label.set_halign(Gtk.Align.START)
|
||||
header_box.pack_start(customer_label, False, False, 0)
|
||||
|
||||
# Location count badge
|
||||
inactive_count = len(self.customer.locations)
|
||||
if inactive_count > 0:
|
||||
count_label = Gtk.Label()
|
||||
count_label.set_markup(
|
||||
f"<span alpha='60%'><small><b>({inactive_count})</b></small></span>")
|
||||
header_box.pack_start(count_label, False, False, 0)
|
||||
|
||||
# Content container (collapsible)
|
||||
self.content_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=12)
|
||||
self.content_box.set_visible(self.expanded) # Start hidden
|
||||
card_vbox.pack_start(self.content_box, 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)
|
||||
self.content_box.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)
|
||||
self.content_box.pack_start(services_vbox, False, False, 0)
|
||||
|
||||
for service in self.customer.services:
|
||||
service_label = Gtk.Label()
|
||||
# Escape special characters in markup text
|
||||
escaped_name = escape_markup(service.name)
|
||||
escaped_type = escape_markup(service.service_type)
|
||||
service_label.set_markup(
|
||||
f"<span alpha='60%'><small>• {escaped_name} ({escaped_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
|
||||
separator = Gtk.Separator(
|
||||
orientation=Gtk.Orientation.HORIZONTAL)
|
||||
separator.set_margin_top(8)
|
||||
separator.set_margin_bottom(8)
|
||||
self.content_box.pack_start(separator, False, False, 0)
|
||||
|
||||
location_card = InactiveLocationCard(
|
||||
location, self.customer.name, self.callbacks)
|
||||
self.content_box.pack_start(location_card.widget, False, False, 0)
|
||||
|
||||
# Show all content in the box (but box itself may be hidden)
|
||||
self.content_box.show_all()
|
||||
|
||||
return card_frame
|
||||
|
||||
def _update_expand_button(self):
|
||||
"""Update the expand button arrow direction."""
|
||||
if self.expanded:
|
||||
self.expand_button.set_label("▼")
|
||||
else:
|
||||
self.expand_button.set_label("▶")
|
||||
|
||||
def _on_expand_toggle(self, button):
|
||||
"""Toggle the expanded state."""
|
||||
self.expanded = not self.expanded
|
||||
self._update_expand_button()
|
||||
self.content_box.set_visible(self.expanded)
|
||||
@@ -1,8 +1,13 @@
|
||||
from models import VPNType
|
||||
from .host_item import HostItem
|
||||
from gi.repository import Gtk
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gtk
|
||||
from .host_item import HostItem
|
||||
from models import VPNType
|
||||
|
||||
|
||||
def escape_markup(text: str) -> str:
|
||||
"""Escape special characters for Pango markup."""
|
||||
return text.replace('&', '&').replace('<', '<').replace('>', '>')
|
||||
|
||||
|
||||
class ActiveLocationCard:
|
||||
@@ -11,49 +16,78 @@ class ActiveLocationCard:
|
||||
self.customer_name = customer_name
|
||||
self.callbacks = callbacks
|
||||
self.widget = self._create_widget()
|
||||
|
||||
|
||||
def _create_widget(self):
|
||||
# Clean card layout - just a box with proper spacing
|
||||
location_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
|
||||
location_vbox = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
|
||||
# Location header with controls
|
||||
header_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
header_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
location_vbox.pack_start(header_box, False, False, 0)
|
||||
|
||||
|
||||
# Location info
|
||||
info_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||
header_box.pack_start(info_vbox, True, True, 0)
|
||||
|
||||
|
||||
# Location name with VPN type
|
||||
location_label = Gtk.Label()
|
||||
location_label.set_markup(f"<b>📍 {self.location.name}</b>")
|
||||
escaped_location_name = escape_markup(self.location.name)
|
||||
location_label.set_markup(f"<b>📍 {escaped_location_name}</b>")
|
||||
location_label.set_halign(Gtk.Align.START)
|
||||
info_vbox.pack_start(location_label, False, False, 0)
|
||||
|
||||
# VPN type
|
||||
|
||||
# VPN type and external address
|
||||
vpn_icons = {
|
||||
VPNType.OPENVPN: "🔒",
|
||||
VPNType.WIREGUARD: "⚡",
|
||||
VPNType.IPSEC: "🛡️"
|
||||
}
|
||||
vpn_icon = vpn_icons.get(self.location.vpn_type, "🔑")
|
||||
|
||||
|
||||
type_text = f"{vpn_icon} {self.location.vpn_type.value} VPN"
|
||||
if self.location.external_addresses:
|
||||
if len(self.location.external_addresses) == 1:
|
||||
type_text += f" • 🌐 {self.location.external_addresses[0]}"
|
||||
else:
|
||||
type_text += f" • 🌐 {len(self.location.external_addresses)} endpoints"
|
||||
|
||||
type_label = Gtk.Label()
|
||||
type_label.set_markup(f"<small>{vpn_icon} {self.location.vpn_type.value} VPN</small>")
|
||||
type_label.set_markup(f"<small>{type_text}</small>")
|
||||
type_label.set_halign(Gtk.Align.START)
|
||||
info_vbox.pack_start(type_label, False, False, 0)
|
||||
|
||||
|
||||
# External addresses and networks if available
|
||||
if self.location.external_addresses and len(self.location.external_addresses) > 1:
|
||||
# Show full list if more than one
|
||||
addresses_text = "🌐 External: " + \
|
||||
", ".join(self.location.external_addresses)
|
||||
addresses_label = Gtk.Label()
|
||||
addresses_label.set_markup(f"<small>{addresses_text}</small>")
|
||||
addresses_label.set_halign(Gtk.Align.START)
|
||||
info_vbox.pack_start(addresses_label, False, False, 0)
|
||||
|
||||
if self.location.networks:
|
||||
networks_text = "📡 Networks: " + ", ".join(self.location.networks)
|
||||
networks_label = Gtk.Label()
|
||||
networks_label.set_markup(f"<small>{networks_text}</small>")
|
||||
networks_label.set_halign(Gtk.Align.START)
|
||||
info_vbox.pack_start(networks_label, False, False, 0)
|
||||
|
||||
# Status and controls
|
||||
controls_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
controls_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
|
||||
header_box.pack_end(controls_box, False, False, 0)
|
||||
|
||||
|
||||
# Status
|
||||
status_text = "● Connected" if self.location.connected else "○ Disconnected"
|
||||
status_color = "#4caf50" if self.location.connected else "#999"
|
||||
status_label = Gtk.Label()
|
||||
status_label.set_markup(f"<small><span color='{status_color}'>{status_text}</span></small>")
|
||||
status_label.set_markup(
|
||||
f"<small><span color='{status_color}'>{status_text}</span></small>")
|
||||
controls_box.pack_start(status_label, False, False, 0)
|
||||
|
||||
|
||||
# Connect/Disconnect button
|
||||
btn_text = "Disconnect" if self.location.connected else "Connect"
|
||||
connect_btn = Gtk.Button(label=btn_text)
|
||||
@@ -63,14 +97,14 @@ class ActiveLocationCard:
|
||||
connect_btn.get_style_context().add_class("suggested-action")
|
||||
connect_btn.connect("clicked", self._on_connect_clicked)
|
||||
controls_box.pack_start(connect_btn, False, False, 0)
|
||||
|
||||
|
||||
# X button to deactivate (close button style)
|
||||
close_btn = Gtk.Button(label="✕")
|
||||
close_btn.set_tooltip_text("Deactivate location")
|
||||
close_btn.get_style_context().add_class("circular")
|
||||
close_btn.connect("clicked", self._on_deactivate_clicked)
|
||||
controls_box.pack_start(close_btn, False, False, 0)
|
||||
|
||||
|
||||
# Hosts section if available
|
||||
if self.location.hosts:
|
||||
hosts_label = Gtk.Label()
|
||||
@@ -78,23 +112,26 @@ class ActiveLocationCard:
|
||||
hosts_label.set_halign(Gtk.Align.START)
|
||||
hosts_label.set_margin_top(8)
|
||||
location_vbox.pack_start(hosts_label, False, False, 0)
|
||||
|
||||
|
||||
# Hosts box with indent
|
||||
hosts_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
hosts_box = Gtk.Box(
|
||||
orientation=Gtk.Orientation.VERTICAL, spacing=8)
|
||||
hosts_box.set_margin_start(16)
|
||||
location_vbox.pack_start(hosts_box, False, False, 0)
|
||||
|
||||
|
||||
for host in self.location.hosts:
|
||||
host_item = HostItem(host, self.callbacks['open_service'])
|
||||
host_item = HostItem(host, self.location,
|
||||
self.callbacks['open_service'])
|
||||
hosts_box.pack_start(host_item.widget, False, False, 0)
|
||||
|
||||
|
||||
return location_vbox
|
||||
|
||||
|
||||
def _on_connect_clicked(self, button):
|
||||
self.callbacks['toggle_connection'](self.location)
|
||||
|
||||
|
||||
def _on_deactivate_clicked(self, button):
|
||||
self.callbacks['deactivate_location'](self.location, self.customer_name)
|
||||
self.callbacks['deactivate_location'](
|
||||
self.location, self.customer_name)
|
||||
|
||||
|
||||
class InactiveLocationCard:
|
||||
@@ -103,54 +140,68 @@ class InactiveLocationCard:
|
||||
self.customer_name = customer_name
|
||||
self.callbacks = callbacks
|
||||
self.widget = self._create_widget()
|
||||
|
||||
|
||||
def _create_widget(self):
|
||||
# Clean horizontal layout
|
||||
location_hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
|
||||
location_hbox = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL, spacing=12)
|
||||
|
||||
# Location info
|
||||
info_vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
|
||||
location_hbox.pack_start(info_vbox, True, True, 0)
|
||||
|
||||
|
||||
# Location name
|
||||
location_label = Gtk.Label()
|
||||
location_label.set_markup(f"<b>📍 {self.location.name}</b>")
|
||||
escaped_location_name = escape_markup(self.location.name)
|
||||
location_label.set_markup(f"<b>📍 {escaped_location_name}</b>")
|
||||
location_label.set_halign(Gtk.Align.START)
|
||||
info_vbox.pack_start(location_label, False, False, 0)
|
||||
|
||||
# VPN type and host count
|
||||
|
||||
# VPN type, external address and host count
|
||||
vpn_icons = {
|
||||
VPNType.OPENVPN: "🔒",
|
||||
VPNType.WIREGUARD: "⚡",
|
||||
VPNType.WIREGUARD: "⚡",
|
||||
VPNType.IPSEC: "🛡️"
|
||||
}
|
||||
vpn_icon = vpn_icons.get(self.location.vpn_type, "🔑")
|
||||
host_count = len(self.location.hosts)
|
||||
|
||||
|
||||
details_text = f"{vpn_icon} {self.location.vpn_type.value} VPN • {host_count} hosts"
|
||||
if self.location.external_addresses:
|
||||
if len(self.location.external_addresses) == 1:
|
||||
details_text += f" • 🌐 {self.location.external_addresses[0]}"
|
||||
else:
|
||||
details_text += f" • 🌐 {len(self.location.external_addresses)} endpoints"
|
||||
if self.location.networks:
|
||||
network_count = len(self.location.networks)
|
||||
details_text += f" • {network_count} network{'s' if network_count > 1 else ''}"
|
||||
|
||||
details_label = Gtk.Label()
|
||||
details_label.set_markup(f"<small>{vpn_icon} {self.location.vpn_type.value} VPN • {host_count} hosts</small>")
|
||||
details_label.set_markup(f"<small>{details_text}</small>")
|
||||
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)
|
||||
button_box.pack_start(activate_btn, False, False, 0)
|
||||
|
||||
|
||||
return location_hbox
|
||||
|
||||
|
||||
def _on_activate_clicked(self, button):
|
||||
self.callbacks['set_location_active'](self.location, self.customer_name)
|
||||
|
||||
self.callbacks['set_location_active'](
|
||||
self.location, self.customer_name)
|
||||
|
||||
def _on_set_current_clicked(self, button):
|
||||
self.callbacks['set_current_location'](self.location, self.customer_name)
|
||||
self.callbacks['set_current_location'](
|
||||
self.location, self.customer_name)
|
||||
|
||||