openvpn-endpoint-server/server/app/web/htmx.py

699 lines
24 KiB
Python

"""HTMX partial routes for dynamic updates."""
from fastapi import APIRouter, Request, Depends, Form
from fastapi.responses import HTMLResponse
from sqlalchemy.orm import Session
from ..database import get_db
from ..models.user import User, UserRole
from ..models.gateway import Gateway
from ..models.endpoint import Endpoint
from ..models.access import UserGatewayAccess, ConnectionLog
from ..models.vpn_server import VPNServer
from ..models.vpn_connection_log import VPNConnectionLog
from ..services.vpn_server_service import VPNServerService
from ..services.vpn_sync_service import VPNSyncService
from .deps import get_current_user_web
router = APIRouter()
@router.get("/dashboard/stats", response_class=HTMLResponse)
async def dashboard_stats(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Dashboard statistics partial."""
gateways_total = db.query(Gateway).count()
gateways_online = db.query(Gateway).filter(Gateway.is_online == True).count()
endpoints_total = db.query(Endpoint).count()
users_total = db.query(User).filter(User.is_active == True).count()
active_connections = db.query(ConnectionLog).filter(
ConnectionLog.disconnected_at.is_(None)
).count()
# Count VPN clients across all active servers
vpn_clients_total = 0
vpn_servers = db.query(VPNServer).filter(VPNServer.is_active == True).all()
service = VPNServerService(db)
for server in vpn_servers:
try:
clients = service.get_connected_clients(server)
vpn_clients_total += len(clients)
except:
pass # Server might be offline
return f"""
<div class="col-md-2 mb-3">
<div class="card stat-card bg-primary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2 text-white-50">Gateways</h6>
<div class="stat-value">{gateways_online} / {gateways_total}</div>
</div>
<i class="bi bi-router stat-icon"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card stat-card bg-success text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2 text-white-50">VPN-Clients</h6>
<div class="stat-value">{vpn_clients_total}</div>
</div>
<i class="bi bi-shield-check stat-icon"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card stat-card bg-info text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2 text-white-50">Sessions</h6>
<div class="stat-value">{active_connections}</div>
</div>
<i class="bi bi-plug stat-icon"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card stat-card bg-secondary text-white">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2 text-white-50">Endpunkte</h6>
<div class="stat-value">{endpoints_total}</div>
</div>
<i class="bi bi-hdd-network stat-icon"></i>
</div>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="card stat-card bg-warning text-dark">
<div class="card-body">
<div class="d-flex justify-content-between">
<div>
<h6 class="card-subtitle mb-2">Benutzer</h6>
<div class="stat-value">{users_total}</div>
</div>
<i class="bi bi-people stat-icon"></i>
</div>
</div>
</div>
</div>
"""
@router.get("/gateways/list", response_class=HTMLResponse)
@router.get("/gateways/search", response_class=HTMLResponse)
@router.get("/gateways/filter", response_class=HTMLResponse)
async def gateway_list_partial(
request: Request,
q: str = "",
status: str = "",
type: str = "",
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Gateway list partial for HTMX."""
query = db.query(Gateway)
# Filter by tenant for non-super-admins
if current_user.role != UserRole.SUPER_ADMIN:
if current_user.role == UserRole.ADMIN:
query = query.filter(Gateway.tenant_id == current_user.tenant_id)
else:
query = query.join(
UserGatewayAccess,
UserGatewayAccess.gateway_id == Gateway.id
).filter(UserGatewayAccess.user_id == current_user.id)
# Apply filters
if q:
query = query.filter(Gateway.name.ilike(f"%{q}%"))
if status == "online":
query = query.filter(Gateway.is_online == True)
elif status == "offline":
query = query.filter(Gateway.is_online == False)
if type:
query = query.filter(Gateway.router_type == type)
gateways = query.all()
if not gateways:
return """
<div class="alert alert-info">
<i class="bi bi-info-circle"></i> Keine Gateways gefunden
</div>
"""
html = '<div class="row">'
for gw in gateways:
status_class = "online" if gw.is_online else "offline"
status_badge = '<span class="badge bg-success">Online</span>' if gw.is_online else '<span class="badge bg-secondary">Offline</span>'
last_seen = gw.last_seen.strftime('%d.%m.%Y %H:%M') if gw.last_seen else 'Nie'
html += f"""
<div class="col-md-6 col-lg-4 mb-3">
<div class="card gateway-card {status_class}">
<div class="card-body">
<h5 class="card-title">
<span class="status-indicator {status_class}"></span>
{gw.name}
</h5>
<p class="card-text text-muted small mb-2">
{gw.router_type} | {gw.location or 'Kein Standort'}
</p>
<p class="card-text small">
{status_badge}
<span class="text-muted ms-2">Zuletzt: {last_seen}</span>
</p>
<a href="/gateways/{gw.id}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> Details
</a>
</div>
</div>
</div>
"""
html += '</div>'
return html
@router.get("/gateways/status-list", response_class=HTMLResponse)
async def gateway_status_list(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Gateway status list for dashboard."""
query = db.query(Gateway)
if current_user.role != UserRole.SUPER_ADMIN:
if current_user.role == UserRole.ADMIN:
query = query.filter(Gateway.tenant_id == current_user.tenant_id)
gateways = query.limit(10).all()
if not gateways:
return '<p class="text-muted">Keine Gateways vorhanden</p>'
html = '<table class="table table-sm table-hover mb-0"><tbody>'
for gw in gateways:
status = '<span class="status-indicator online"></span>' if gw.is_online else '<span class="status-indicator offline"></span>'
html += f"""
<tr onclick="window.location='/gateways/{gw.id}'" style="cursor:pointer">
<td>{status} {gw.name}</td>
<td class="text-muted">{gw.router_type}</td>
<td class="text-end">
<a href="/gateways/{gw.id}" class="btn btn-sm btn-link">
<i class="bi bi-arrow-right"></i>
</a>
</td>
</tr>
"""
html += '</tbody></table>'
return html
@router.get("/gateways/{gateway_id}/endpoints", response_class=HTMLResponse)
async def gateway_endpoints_partial(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Endpoints list partial."""
endpoints = db.query(Endpoint).filter(Endpoint.gateway_id == gateway_id).all()
if not endpoints:
return """
<div class="text-center text-muted py-4">
<i class="bi bi-inbox" style="font-size: 2rem;"></i>
<p>Keine Endpunkte definiert</p>
</div>
"""
html = '<table class="table table-hover mb-0"><thead><tr><th>Name</th><th>Adresse</th><th>Protokoll</th><th>Anwendung</th><th></th></tr></thead><tbody>'
for ep in endpoints:
protocol_badge = f'<span class="badge badge-{ep.protocol.value}">{ep.protocol.value.upper()}</span>'
html += f"""
<tr>
<td><strong>{ep.name}</strong></td>
<td><code>{ep.internal_ip}:{ep.port}</code></td>
<td>{protocol_badge}</td>
<td>{ep.application_name or '-'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger"
hx-delete="/htmx/endpoints/{ep.id}"
hx-confirm="Endpunkt '{ep.name}' löschen?"
hx-target="#endpoints-list"
hx-swap="innerHTML">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
"""
html += '</tbody></table>'
return html
@router.post("/gateways/{gateway_id}/endpoints", response_class=HTMLResponse)
async def create_endpoint_htmx(
request: Request,
gateway_id: int,
name: str = Form(...),
internal_ip: str = Form(...),
port: int = Form(...),
protocol: str = Form("tcp"),
application_template_id: int = Form(None),
description: str = Form(None),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Create endpoint via HTMX."""
from ..models.endpoint import Protocol
endpoint = Endpoint(
gateway_id=gateway_id,
name=name,
internal_ip=internal_ip,
port=port,
protocol=Protocol(protocol),
application_template_id=application_template_id if application_template_id else None,
description=description
)
db.add(endpoint)
db.commit()
# Return updated list
return await gateway_endpoints_partial(request, gateway_id, db, current_user)
@router.delete("/endpoints/{endpoint_id}", response_class=HTMLResponse)
async def delete_endpoint_htmx(
request: Request,
endpoint_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Delete endpoint via HTMX."""
endpoint = db.query(Endpoint).filter(Endpoint.id == endpoint_id).first()
if endpoint:
gateway_id = endpoint.gateway_id
db.delete(endpoint)
db.commit()
return await gateway_endpoints_partial(request, gateway_id, db, current_user)
return ""
@router.get("/gateways/{gateway_id}/access", response_class=HTMLResponse)
async def gateway_access_partial(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""User access list partial for gateway."""
access_list = db.query(UserGatewayAccess).filter(
UserGatewayAccess.gateway_id == gateway_id
).all()
if not access_list:
return """
<div class="text-center text-muted py-4">
<i class="bi bi-people" style="font-size: 2rem;"></i>
<p>Keine Benutzer haben Zugriff</p>
</div>
"""
html = '<table class="table table-hover mb-0"><thead><tr><th>Benutzer</th><th>Rolle</th><th>Gewährt am</th><th>Gewährt von</th><th></th></tr></thead><tbody>'
for access in access_list:
user = db.query(User).filter(User.id == access.user_id).first()
granted_by = db.query(User).filter(User.id == access.granted_by_id).first() if access.granted_by_id else None
granted_at = access.granted_at.strftime('%d.%m.%Y') if access.granted_at else '-'
html += f"""
<tr>
<td><strong>{user.username if user else '-'}</strong></td>
<td><span class="badge bg-secondary">{user.role.value if user else '-'}</span></td>
<td>{granted_at}</td>
<td>{granted_by.username if granted_by else '-'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-danger"
hx-delete="/htmx/gateways/{gateway_id}/access/{access.user_id}"
hx-confirm="Zugriff entziehen?"
hx-target="#access-list"
hx-swap="innerHTML">
<i class="bi bi-x-lg"></i>
</button>
</td>
</tr>
"""
html += '</tbody></table>'
return html
@router.post("/gateways/{gateway_id}/access", response_class=HTMLResponse)
async def grant_access_htmx(
request: Request,
gateway_id: int,
user_id: int = Form(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Grant user access to gateway via HTMX."""
# Check if access already exists
existing = db.query(UserGatewayAccess).filter(
UserGatewayAccess.gateway_id == gateway_id,
UserGatewayAccess.user_id == user_id
).first()
if not existing:
access = UserGatewayAccess(
gateway_id=gateway_id,
user_id=user_id,
granted_by_id=current_user.id
)
db.add(access)
db.commit()
return await gateway_access_partial(request, gateway_id, db, current_user)
@router.delete("/gateways/{gateway_id}/access/{user_id}", response_class=HTMLResponse)
async def revoke_access_htmx(
request: Request,
gateway_id: int,
user_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Revoke user access to gateway via HTMX."""
access = db.query(UserGatewayAccess).filter(
UserGatewayAccess.gateway_id == gateway_id,
UserGatewayAccess.user_id == user_id
).first()
if access:
db.delete(access)
db.commit()
return await gateway_access_partial(request, gateway_id, db, current_user)
@router.get("/gateways/{gateway_id}/vpn-log", response_class=HTMLResponse)
async def gateway_vpn_log_partial(
request: Request,
gateway_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""VPN connection log for gateway."""
sync_service = VPNSyncService(db)
logs = sync_service.get_gateway_connection_logs(gateway_id, limit=20)
if not logs:
return """
<div class="text-center text-muted py-4">
<i class="bi bi-shield-x" style="font-size: 2rem;"></i>
<p>Keine VPN-Verbindungen aufgezeichnet</p>
</div>
"""
html = '''<table class="table table-hover table-sm mb-0">
<thead>
<tr>
<th>Profil</th>
<th>Server</th>
<th>Echte Adresse</th>
<th>Verbunden</th>
<th>Getrennt</th>
<th>Dauer</th>
<th>Traffic</th>
</tr>
</thead>
<tbody>'''
for log in logs:
profile_name = log.vpn_profile.name if log.vpn_profile else '-'
server_name = log.vpn_server.name if log.vpn_server else '-'
real_addr = log.real_address or '-'
connected = log.connected_at.strftime('%d.%m. %H:%M') if log.connected_at else '-'
if log.disconnected_at:
disconnected = log.disconnected_at.strftime('%H:%M')
duration = log.duration_seconds or 0
if duration >= 3600:
duration_str = f"{duration // 3600}h {(duration % 3600) // 60}m"
elif duration >= 60:
duration_str = f"{duration // 60}m"
else:
duration_str = f"{duration}s"
status_badge = ''
else:
disconnected = '<span class="badge bg-success">Aktiv</span>'
duration_str = '-'
status_badge = ''
rx = log.bytes_received or 0
tx = log.bytes_sent or 0
rx_str = f"{rx / 1024 / 1024:.1f}" if rx > 1024*1024 else f"{rx / 1024:.0f}K"
tx_str = f"{tx / 1024 / 1024:.1f}" if tx > 1024*1024 else f"{tx / 1024:.0f}K"
traffic = f"{rx_str}{tx_str}"
html += f'''
<tr>
<td>{profile_name}</td>
<td>{server_name}</td>
<td><code class="small">{real_addr}</code></td>
<td>{connected}</td>
<td>{disconnected}</td>
<td>{duration_str}</td>
<td class="small">{traffic}</td>
</tr>'''
html += '</tbody></table>'
return html
@router.get("/connections/count", response_class=HTMLResponse)
async def connections_count(
db: Session = Depends(get_db)
):
"""Active connections count."""
count = db.query(ConnectionLog).filter(
ConnectionLog.disconnected_at.is_(None)
).count()
# Also count VPN clients
vpn_clients = 0
vpn_servers = db.query(VPNServer).filter(VPNServer.is_active == True).all()
service = VPNServerService(db)
for server in vpn_servers:
try:
clients = service.get_connected_clients(server)
vpn_clients += len(clients)
except:
pass
return f'<i class="bi bi-shield-check"></i> {vpn_clients} VPN-Clients &nbsp; <i class="bi bi-plug"></i> {count} Sessions'
@router.get("/connections/vpn-clients", response_class=HTMLResponse)
async def vpn_clients_list(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""List of connected VPN clients (gateways)."""
# Sync connections with database (updates gateway status, creates logs)
sync_service = VPNSyncService(db)
sync_service.sync_all_connections()
# Get active connections from database
active_connections = sync_service.get_active_connections()
if not active_connections:
return '<p class="text-muted text-center py-3">Keine VPN-Clients verbunden</p>'
html = '''<table class="table table-hover table-sm">
<thead>
<tr>
<th>Gateway</th>
<th>Profil</th>
<th>VPN-Server</th>
<th>Echte Adresse</th>
<th>Empfangen</th>
<th>Gesendet</th>
<th>Verbunden seit</th>
</tr>
</thead>
<tbody>'''
for conn in active_connections:
gateway_name = conn.gateway.name if conn.gateway else '-'
gateway_id = conn.gateway.id if conn.gateway else ''
profile_name = conn.vpn_profile.name if conn.vpn_profile else '-'
server_name = conn.vpn_server.name if conn.vpn_server else '-'
server_id = conn.vpn_server.id if conn.vpn_server else ''
real_addr = conn.real_address or '-'
rx = conn.bytes_received or 0
tx = conn.bytes_sent or 0
connected = conn.connected_at.strftime('%d.%m.%Y %H:%M') if conn.connected_at else '-'
# Format bytes
rx_str = f"{rx / 1024 / 1024:.2f} MB" if rx > 1024*1024 else f"{rx / 1024:.1f} KB"
tx_str = f"{tx / 1024 / 1024:.2f} MB" if tx > 1024*1024 else f"{tx / 1024:.1f} KB"
html += f'''
<tr>
<td><a href="/gateways/{gateway_id}"><strong>{gateway_name}</strong></a></td>
<td>{profile_name}</td>
<td><a href="/vpn-servers/{server_id}">{server_name}</a></td>
<td><code>{real_addr}</code></td>
<td>{rx_str}</td>
<td>{tx_str}</td>
<td>{connected}</td>
</tr>'''
html += '</tbody></table>'
return html
@router.get("/connections/active", response_class=HTMLResponse)
async def active_connections(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Active connections partial."""
connections = db.query(ConnectionLog).filter(
ConnectionLog.disconnected_at.is_(None)
).all()
if not connections:
return '<p class="text-muted text-center py-3">Keine aktiven Verbindungen</p>'
html = '<ul class="list-group list-group-flush">'
for conn in connections:
user = db.query(User).filter(User.id == conn.user_id).first()
gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first()
endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first() if conn.endpoint_id else None
html += f"""
<li class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong>{user.username if user else 'Unknown'}</strong>
<i class="bi bi-arrow-right mx-2"></i>
{gateway.name if gateway else 'Unknown'}
{f' / {endpoint.name}' if endpoint else ''}
</div>
<span class="badge bg-success"><i class="bi bi-broadcast"></i> Verbunden</span>
</li>
"""
html += '</ul>'
return html
@router.get("/connections/recent", response_class=HTMLResponse)
async def recent_connections(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Recent connections for dashboard."""
connections = db.query(ConnectionLog).order_by(
ConnectionLog.connected_at.desc()
).limit(5).all()
if not connections:
return '<p class="text-muted text-center">Keine Verbindungen</p>'
html = '<ul class="list-group list-group-flush">'
for conn in connections:
user = db.query(User).filter(User.id == conn.user_id).first()
gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first()
time = conn.connected_at.strftime('%H:%M')
if conn.disconnected_at:
status = '<span class="text-muted"><i class="bi bi-x-circle"></i></span>'
else:
status = '<span class="text-success"><i class="bi bi-check-circle"></i></span>'
html += f"""
<li class="list-group-item d-flex justify-content-between align-items-center py-2">
<span>
{status}
<span class="ms-2">{user.username if user else '?'}</span>
<i class="bi bi-arrow-right text-muted mx-1"></i>
<span class="text-muted">{gateway.name if gateway else '?'}</span>
</span>
<small class="text-muted">{time}</small>
</li>
"""
html += '</ul>'
return html
@router.get("/connections/list", response_class=HTMLResponse)
async def connections_list(
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user_web)
):
"""Connection history list."""
connections = db.query(ConnectionLog).order_by(
ConnectionLog.connected_at.desc()
).limit(50).all()
if not connections:
return '<p class="text-muted text-center py-4">Keine Verbindungshistorie</p>'
html = '<table class="table table-hover"><thead><tr><th>Benutzer</th><th>Gateway</th><th>Endpunkt</th><th>Verbunden</th><th>Getrennt</th><th>Dauer</th></tr></thead><tbody>'
for conn in connections:
user = db.query(User).filter(User.id == conn.user_id).first()
gateway = db.query(Gateway).filter(Gateway.id == conn.gateway_id).first()
endpoint = db.query(Endpoint).filter(Endpoint.id == conn.endpoint_id).first() if conn.endpoint_id else None
connected = conn.connected_at.strftime('%d.%m.%Y %H:%M')
disconnected = conn.disconnected_at.strftime('%H:%M') if conn.disconnected_at else '<span class="badge bg-success">Aktiv</span>'
duration = ""
if conn.duration_seconds:
mins = conn.duration_seconds // 60
duration = f"{mins} Min."
html += f"""
<tr>
<td>{user.username if user else '-'}</td>
<td>{gateway.name if gateway else '-'}</td>
<td>{endpoint.name if endpoint else '-'}</td>
<td>{connected}</td>
<td>{disconnected}</td>
<td>{duration}</td>
</tr>
"""
html += '</tbody></table>'
return html