Initial commit - Phase 3/4
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,39 +1,62 @@
|
||||
"""System API endpoints."""
|
||||
"""System API endpoints.
|
||||
|
||||
Refactored to use services for business logic.
|
||||
Session management uses PM3Service, system operations use SystemService.
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional, Dict
|
||||
|
||||
from ..managers.session_manager import SessionManager
|
||||
from ..services.container import container
|
||||
from ..managers.ups_manager import get_ups_manager
|
||||
from ..managers.ble_manager import get_ble_manager
|
||||
|
||||
router = APIRouter()
|
||||
session_manager = SessionManager()
|
||||
|
||||
|
||||
class CreateSessionRequest(BaseModel):
|
||||
force_takeover: bool = False
|
||||
device_id: Optional[str] = None # Device to create session for
|
||||
|
||||
|
||||
class CreateSessionResponse(BaseModel):
|
||||
success: bool
|
||||
session_id: Optional[str] = None
|
||||
device_id: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
|
||||
|
||||
class SessionInfo(BaseModel):
|
||||
session_id: str
|
||||
device_id: Optional[str]
|
||||
client_ip: str
|
||||
created_at: float
|
||||
last_activity: float
|
||||
time_remaining: float
|
||||
|
||||
|
||||
class CPUCoreInfo(BaseModel):
|
||||
"""Per-core CPU information."""
|
||||
core_id: int
|
||||
percent: float
|
||||
online: bool = True # Whether this core is currently enabled
|
||||
|
||||
|
||||
class CPUInfo(BaseModel):
|
||||
"""CPU information including per-core data."""
|
||||
count: int
|
||||
percent: float
|
||||
temperature: Optional[float] = None
|
||||
per_core: list[CPUCoreInfo] = []
|
||||
load_average: Optional[list[float]] = None
|
||||
|
||||
|
||||
class SystemInfo(BaseModel):
|
||||
"""System information response."""
|
||||
hostname: str
|
||||
uptime: float
|
||||
cpu_temp: Optional[float]
|
||||
cpu: Optional[CPUInfo] = None
|
||||
memory_used: float
|
||||
memory_total: float
|
||||
disk_used: float
|
||||
@@ -42,38 +65,70 @@ class SystemInfo(BaseModel):
|
||||
|
||||
@router.post("/session/create", response_model=CreateSessionResponse)
|
||||
async def create_session(request: Request, body: CreateSessionRequest):
|
||||
"""Create a new session for PM3 access."""
|
||||
"""Create a new session for PM3 access.
|
||||
|
||||
Uses PM3Service for session management.
|
||||
Optionally specify device_id for multi-device support.
|
||||
"""
|
||||
# Get client IP from request
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
user_agent = request.headers.get("user-agent")
|
||||
|
||||
success, session_id, error = await session_manager.create_session(
|
||||
result = await container.pm3_service.create_session(
|
||||
client_ip=client_ip,
|
||||
user_agent=user_agent,
|
||||
force_takeover=body.force_takeover
|
||||
force_takeover=body.force_takeover,
|
||||
device_id=body.device_id
|
||||
)
|
||||
|
||||
return CreateSessionResponse(
|
||||
success=success,
|
||||
session_id=session_id,
|
||||
error=error
|
||||
)
|
||||
if result.success:
|
||||
return CreateSessionResponse(
|
||||
success=True,
|
||||
session_id=result.data["session_id"],
|
||||
device_id=result.data.get("device_id"),
|
||||
error=None
|
||||
)
|
||||
else:
|
||||
return CreateSessionResponse(
|
||||
success=False,
|
||||
session_id=None,
|
||||
device_id=None,
|
||||
error=result.error.message
|
||||
)
|
||||
|
||||
|
||||
@router.post("/session/{session_id}/release")
|
||||
async def release_session(session_id: str):
|
||||
"""Release an active session."""
|
||||
success = await session_manager.release_session(session_id)
|
||||
async def release_session(session_id: str, device_id: Optional[str] = None):
|
||||
"""Release an active session.
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
Uses PM3Service for session management.
|
||||
|
||||
return {"success": True, "message": "Session released"}
|
||||
Args:
|
||||
session_id: Session ID to release
|
||||
device_id: Optional device ID for faster lookup
|
||||
"""
|
||||
result = await container.pm3_service.release_session(session_id, device_id)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
return {"success": True, "message": result.data["message"]}
|
||||
|
||||
|
||||
@router.get("/session/active", response_model=Optional[SessionInfo])
|
||||
async def get_active_session():
|
||||
"""Get information about the active session."""
|
||||
session = session_manager.get_active_session()
|
||||
async def get_active_session(device_id: Optional[str] = None):
|
||||
"""Get information about the active session for a device.
|
||||
|
||||
Uses PM3Service (via SessionManager) for session info.
|
||||
|
||||
Args:
|
||||
device_id: Optional device ID. If None, returns any active session.
|
||||
"""
|
||||
# Access session manager through container for consistency
|
||||
session = container.session_manager.get_active_session(device_id)
|
||||
|
||||
if not session:
|
||||
return None
|
||||
@@ -85,6 +140,7 @@ async def get_active_session():
|
||||
|
||||
return SessionInfo(
|
||||
session_id=session.session_id,
|
||||
device_id=session.device_id,
|
||||
client_ip=session.client_ip,
|
||||
created_at=session.created_at,
|
||||
last_activity=session.last_activity,
|
||||
@@ -92,36 +148,63 @@ async def get_active_session():
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/all")
|
||||
async def get_all_sessions():
|
||||
"""Get all active sessions across all devices.
|
||||
|
||||
Returns a list of all active sessions with their device IDs.
|
||||
"""
|
||||
result = container.pm3_service.get_all_sessions()
|
||||
return result.data
|
||||
|
||||
|
||||
@router.get("/info", response_model=SystemInfo)
|
||||
async def get_system_info():
|
||||
"""Get system information."""
|
||||
import platform
|
||||
import psutil
|
||||
from pathlib import Path
|
||||
"""Get system information.
|
||||
|
||||
# Get CPU temperature (Raspberry Pi specific)
|
||||
cpu_temp = None
|
||||
try:
|
||||
temp_file = Path("/sys/class/thermal/thermal_zone0/temp")
|
||||
if temp_file.exists():
|
||||
cpu_temp = int(temp_file.read_text()) / 1000.0
|
||||
except Exception:
|
||||
pass
|
||||
Refactored to use SystemService for business logic.
|
||||
"""
|
||||
from ..services.container import container
|
||||
|
||||
# Get memory info
|
||||
memory = psutil.virtual_memory()
|
||||
result = await container.system_service.get_info()
|
||||
|
||||
# Get disk info
|
||||
disk = psutil.disk_usage('/')
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
cpu_data = result.data["cpu"]
|
||||
memory_data = result.data["memory"]
|
||||
disk_data = result.data["disk"]
|
||||
|
||||
# Build per-core CPU info
|
||||
per_core = [
|
||||
CPUCoreInfo(
|
||||
core_id=c["core_id"],
|
||||
percent=c["percent"],
|
||||
online=c.get("online", True)
|
||||
)
|
||||
for c in cpu_data.get("per_core", [])
|
||||
]
|
||||
|
||||
cpu_info = CPUInfo(
|
||||
count=cpu_data.get("count", 0),
|
||||
percent=cpu_data.get("percent", 0.0),
|
||||
temperature=cpu_data.get("temperature"),
|
||||
per_core=per_core,
|
||||
load_average=cpu_data.get("load_average")
|
||||
)
|
||||
|
||||
return SystemInfo(
|
||||
hostname=platform.node(),
|
||||
uptime=psutil.boot_time(),
|
||||
cpu_temp=cpu_temp,
|
||||
memory_used=memory.used,
|
||||
memory_total=memory.total,
|
||||
disk_used=disk.used,
|
||||
disk_total=disk.total
|
||||
hostname=result.data["hostname"],
|
||||
uptime=result.data["uptime"],
|
||||
cpu_temp=cpu_data.get("temperature"),
|
||||
cpu=cpu_info,
|
||||
memory_used=memory_data["used"],
|
||||
memory_total=memory_data["total"],
|
||||
disk_used=disk_data["used"],
|
||||
disk_total=disk_data["total"]
|
||||
)
|
||||
|
||||
|
||||
@@ -129,11 +212,15 @@ async def get_system_info():
|
||||
async def get_config():
|
||||
"""Get system configuration (non-sensitive values)."""
|
||||
from .. import config
|
||||
from ..services.container import container
|
||||
|
||||
# Get WiFi mode from manager, default to AP mode
|
||||
wifi_mode = container.wifi_manager._current_mode.value if container.wifi_manager else "ap"
|
||||
|
||||
return {
|
||||
"pm3_device": config.PM3_DEVICE,
|
||||
"session_timeout": config.SESSION_TIMEOUT,
|
||||
"wifi_mode": "auto", # TODO: Get from wifi manager
|
||||
"wifi_mode": wifi_mode,
|
||||
"ble_enabled": config.BLE_ENABLED,
|
||||
"auth_enabled": config.AUTH_ENABLED,
|
||||
"https_enabled": config.HTTPS_ENABLED
|
||||
@@ -141,17 +228,47 @@ async def get_config():
|
||||
|
||||
|
||||
@router.post("/restart")
|
||||
async def restart_system():
|
||||
"""Restart the backend application."""
|
||||
# TODO: Implement graceful restart
|
||||
return {"success": True, "message": "Restart initiated"}
|
||||
async def restart_system(delay: int = 0):
|
||||
"""Restart the system.
|
||||
|
||||
Refactored to use SystemService for business logic.
|
||||
|
||||
Args:
|
||||
delay: Delay in seconds before restart (default: 0)
|
||||
"""
|
||||
from ..services.container import container
|
||||
|
||||
result = await container.system_service.restart(delay=delay)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
return {"success": True, "message": result.data["message"]}
|
||||
|
||||
|
||||
@router.post("/shutdown")
|
||||
async def shutdown_system():
|
||||
"""Initiate system shutdown."""
|
||||
# TODO: Implement safe shutdown sequence
|
||||
return {"success": True, "message": "Shutdown initiated"}
|
||||
async def shutdown_system(delay: int = 0):
|
||||
"""Initiate system shutdown.
|
||||
|
||||
Refactored to use SystemService for business logic.
|
||||
|
||||
Args:
|
||||
delay: Delay in seconds before shutdown (default: 0)
|
||||
"""
|
||||
from ..services.container import container
|
||||
|
||||
result = await container.system_service.shutdown(delay=delay)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
return {"success": True, "message": result.data["message"]}
|
||||
|
||||
|
||||
class UPSStatusResponse(BaseModel):
|
||||
@@ -174,6 +291,21 @@ class UPSThresholdsRequest(BaseModel):
|
||||
warning_threshold: Optional[float] = None
|
||||
|
||||
|
||||
class PowerRestrictionsResponse(BaseModel):
|
||||
"""Power restrictions response model."""
|
||||
restricted: bool
|
||||
reason: Optional[str] = None
|
||||
power_source: str
|
||||
ups_available: bool
|
||||
battery_percentage: Optional[float] = None
|
||||
allow_firmware_flash: bool
|
||||
allow_bootloader_flash: bool
|
||||
allow_intensive_operations: bool
|
||||
message: Optional[str] = None
|
||||
warning: Optional[str] = None
|
||||
shutdown_imminent: Optional[bool] = None
|
||||
|
||||
|
||||
@router.get("/ups/status", response_model=UPSStatusResponse)
|
||||
async def get_ups_status():
|
||||
"""Get current UPS battery status."""
|
||||
@@ -227,6 +359,140 @@ async def trigger_ups_shutdown(delay: int = 30):
|
||||
}
|
||||
|
||||
|
||||
@router.get("/power/restrictions", response_model=PowerRestrictionsResponse)
|
||||
async def get_power_restrictions():
|
||||
"""Get current power restrictions based on UPS/battery state.
|
||||
|
||||
Returns power policy information including:
|
||||
- Whether operations are restricted
|
||||
- Current power source (AC, battery, or assumed AC if no UPS)
|
||||
- Battery level (if UPS present)
|
||||
- Which operations are allowed (firmware flash, bootloader flash, etc.)
|
||||
- User-friendly messages and warnings
|
||||
|
||||
This endpoint is critical for determining whether power-intensive
|
||||
operations (like firmware flashing) should be allowed.
|
||||
|
||||
If UPS hardware is not detected, assumes stable AC power and allows
|
||||
all operations (user responsibility to ensure power stability).
|
||||
"""
|
||||
ups_manager = get_ups_manager()
|
||||
restrictions = ups_manager.get_power_restrictions()
|
||||
|
||||
return PowerRestrictionsResponse(**restrictions)
|
||||
|
||||
|
||||
class PiModelResponse(BaseModel):
|
||||
"""Pi model information response."""
|
||||
model: str
|
||||
model_short: str
|
||||
total_cores: int
|
||||
default_active_cores: int
|
||||
min_cores: int
|
||||
max_cores: int
|
||||
|
||||
|
||||
class CPUCoresConfigResponse(BaseModel):
|
||||
"""CPU cores configuration response."""
|
||||
total_cores: int
|
||||
online_cores: int
|
||||
configured_cores: Optional[int] = None # From cmdline.txt maxcpus parameter
|
||||
configurable_cores: list[int]
|
||||
pi_model: Optional[PiModelResponse] = None
|
||||
|
||||
|
||||
class SetCPUCoresRequest(BaseModel):
|
||||
"""Request to set CPU cores."""
|
||||
num_cores: int
|
||||
persist: bool = True # Save to config for boot persistence
|
||||
reboot: bool = True # Reboot after saving
|
||||
|
||||
|
||||
@router.get("/cpu/cores", response_model=CPUCoresConfigResponse)
|
||||
async def get_cpu_cores():
|
||||
"""Get CPU cores configuration.
|
||||
|
||||
Returns information about:
|
||||
- Total physical cores
|
||||
- Currently online cores
|
||||
- Which cores can be toggled (core 0 is always on)
|
||||
- Pi model information with recommended defaults
|
||||
"""
|
||||
result = await container.system_service.get_cpu_cores_config()
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
# Convert pi_model dict to response model if present
|
||||
pi_model_data = result.data.get("pi_model")
|
||||
pi_model = None
|
||||
if pi_model_data:
|
||||
pi_model = PiModelResponse(**pi_model_data)
|
||||
|
||||
return CPUCoresConfigResponse(
|
||||
total_cores=result.data["total_cores"],
|
||||
online_cores=result.data["online_cores"],
|
||||
configured_cores=result.data.get("configured_cores"),
|
||||
configurable_cores=result.data["configurable_cores"],
|
||||
pi_model=pi_model
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cpu/cores")
|
||||
async def set_cpu_cores(request: SetCPUCoresRequest):
|
||||
"""Set the number of active CPU cores.
|
||||
|
||||
Core 0 is always active. This endpoint enables/disables cores 1 through N.
|
||||
|
||||
For Pi Zero 2 W, the recommended default is 2 cores (out of 4) for
|
||||
better thermal management and power efficiency.
|
||||
|
||||
Args:
|
||||
num_cores: Number of cores to keep active (1 to max_cores)
|
||||
persist: Save setting to config file (default: True)
|
||||
reboot: Reboot system after saving (default: True)
|
||||
"""
|
||||
result = await container.system_service.set_cpu_cores(
|
||||
num_cores=request.num_cores,
|
||||
persist=request.persist,
|
||||
reboot=request.reboot
|
||||
)
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=400 if result.error.code == "invalid_cores" else 500,
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": result.data["message"],
|
||||
"requested": result.data.get("requested"),
|
||||
"actual": result.data.get("actual"),
|
||||
"rebooting": result.data.get("rebooting", False)
|
||||
}
|
||||
|
||||
|
||||
@router.get("/pi/model", response_model=PiModelResponse)
|
||||
async def get_pi_model():
|
||||
"""Get Raspberry Pi model information.
|
||||
|
||||
Returns the detected Pi model with recommended CPU core settings.
|
||||
"""
|
||||
result = await container.system_service.get_pi_model()
|
||||
|
||||
if not result.success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=result.error.message
|
||||
)
|
||||
|
||||
return PiModelResponse(**result.data)
|
||||
|
||||
|
||||
class BLEStatusResponse(BaseModel):
|
||||
"""BLE status response model."""
|
||||
enabled: bool
|
||||
@@ -307,3 +573,317 @@ async def send_ble_notification(request: SendNotificationRequest):
|
||||
"success": True,
|
||||
"message": "Notification sent"
|
||||
}
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# SSL/HTTPS API
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SSLCertificateInfo(BaseModel):
|
||||
"""SSL certificate information."""
|
||||
enabled: bool
|
||||
certificate_exists: bool
|
||||
certificate_path: Optional[str] = None
|
||||
key_path: Optional[str] = None
|
||||
subject: Optional[str] = None
|
||||
issuer: Optional[str] = None
|
||||
not_before: Optional[str] = None
|
||||
not_after: Optional[str] = None
|
||||
san: Optional[list[str]] = None # Subject Alternative Names
|
||||
fingerprint_sha256: Optional[str] = None
|
||||
|
||||
|
||||
class SSLRegenerateRequest(BaseModel):
|
||||
"""Request to regenerate SSL certificate."""
|
||||
reload_nginx: bool = True
|
||||
|
||||
|
||||
@router.get("/ssl/info", response_model=SSLCertificateInfo)
|
||||
async def get_ssl_info():
|
||||
"""Get SSL certificate information.
|
||||
|
||||
Returns details about the current SSL configuration including:
|
||||
- Whether HTTPS is enabled
|
||||
- Certificate existence and paths
|
||||
- Certificate subject, issuer, validity dates
|
||||
- Subject Alternative Names (SANs)
|
||||
- SHA256 fingerprint
|
||||
"""
|
||||
import os
|
||||
import subprocess
|
||||
from .. import config
|
||||
|
||||
cert_path = "/opt/dangerous-pi/ssl/dangerous-pi.crt"
|
||||
key_path = "/opt/dangerous-pi/ssl/dangerous-pi.key"
|
||||
|
||||
# Check if HTTPS is enabled and certificate exists
|
||||
https_enabled = config.HTTPS_ENABLED
|
||||
cert_exists = os.path.exists(cert_path)
|
||||
key_exists = os.path.exists(key_path)
|
||||
|
||||
if not cert_exists:
|
||||
return SSLCertificateInfo(
|
||||
enabled=https_enabled,
|
||||
certificate_exists=False,
|
||||
certificate_path=cert_path,
|
||||
key_path=key_path
|
||||
)
|
||||
|
||||
# Parse certificate details using openssl
|
||||
cert_info = {
|
||||
"subject": None,
|
||||
"issuer": None,
|
||||
"not_before": None,
|
||||
"not_after": None,
|
||||
"san": [],
|
||||
"fingerprint": None
|
||||
}
|
||||
|
||||
try:
|
||||
# Get subject
|
||||
result = subprocess.run(
|
||||
["openssl", "x509", "-in", cert_path, "-noout", "-subject"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
cert_info["subject"] = result.stdout.strip().replace("subject=", "")
|
||||
|
||||
# Get issuer
|
||||
result = subprocess.run(
|
||||
["openssl", "x509", "-in", cert_path, "-noout", "-issuer"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
cert_info["issuer"] = result.stdout.strip().replace("issuer=", "")
|
||||
|
||||
# Get validity dates
|
||||
result = subprocess.run(
|
||||
["openssl", "x509", "-in", cert_path, "-noout", "-dates"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line.startswith("notBefore="):
|
||||
cert_info["not_before"] = line.replace("notBefore=", "")
|
||||
elif line.startswith("notAfter="):
|
||||
cert_info["not_after"] = line.replace("notAfter=", "")
|
||||
|
||||
# Get SANs
|
||||
result = subprocess.run(
|
||||
["openssl", "x509", "-in", cert_path, "-noout", "-ext", "subjectAltName"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0 and "subjectAltName" in result.stdout:
|
||||
# Parse SANs from output like "DNS:localhost, IP:192.168.4.1"
|
||||
san_line = result.stdout.strip()
|
||||
for line in san_line.split("\n"):
|
||||
if "DNS:" in line or "IP:" in line:
|
||||
# Split by comma and clean up
|
||||
sans = [s.strip() for s in line.split(",")]
|
||||
cert_info["san"] = [s for s in sans if s.startswith(("DNS:", "IP:"))]
|
||||
break
|
||||
|
||||
# Get SHA256 fingerprint
|
||||
result = subprocess.run(
|
||||
["openssl", "x509", "-in", cert_path, "-noout", "-fingerprint", "-sha256"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode == 0:
|
||||
# Format: "sha256 Fingerprint=XX:XX:XX..."
|
||||
fingerprint = result.stdout.strip()
|
||||
if "=" in fingerprint:
|
||||
cert_info["fingerprint"] = fingerprint.split("=", 1)[1]
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
pass # Return what we have
|
||||
except Exception:
|
||||
pass # Return what we have
|
||||
|
||||
return SSLCertificateInfo(
|
||||
enabled=https_enabled,
|
||||
certificate_exists=cert_exists and key_exists,
|
||||
certificate_path=cert_path,
|
||||
key_path=key_path,
|
||||
subject=cert_info["subject"],
|
||||
issuer=cert_info["issuer"],
|
||||
not_before=cert_info["not_before"],
|
||||
not_after=cert_info["not_after"],
|
||||
san=cert_info["san"] if cert_info["san"] else None,
|
||||
fingerprint_sha256=cert_info["fingerprint"]
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ssl/regenerate")
|
||||
async def regenerate_ssl_certificate(request: SSLRegenerateRequest):
|
||||
"""Regenerate the self-signed SSL certificate.
|
||||
|
||||
This will:
|
||||
1. Generate a new EC P-256 key and self-signed certificate
|
||||
2. Optionally reload nginx to pick up the new certificate
|
||||
|
||||
The new certificate will be valid for 10 years and include SANs for:
|
||||
- 192.168.4.1 (AP gateway IP)
|
||||
- dangerous-pi.local (mDNS hostname)
|
||||
- localhost
|
||||
|
||||
Args:
|
||||
reload_nginx: Whether to reload nginx after regeneration (default: True)
|
||||
|
||||
Returns:
|
||||
Success status and certificate info
|
||||
"""
|
||||
import subprocess
|
||||
import os
|
||||
|
||||
script_path = "/opt/dangerous-pi/scripts/generate-ssl-cert.sh"
|
||||
|
||||
# Check if script exists
|
||||
if not os.path.exists(script_path):
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="SSL certificate generation script not found"
|
||||
)
|
||||
|
||||
try:
|
||||
# Run certificate generation with --force flag
|
||||
result = subprocess.run(
|
||||
[script_path, "--force"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Certificate generation failed: {result.stderr}"
|
||||
)
|
||||
|
||||
# Reload nginx if requested
|
||||
nginx_reloaded = False
|
||||
if request.reload_nginx:
|
||||
try:
|
||||
nginx_result = subprocess.run(
|
||||
["systemctl", "reload", "nginx"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
nginx_reloaded = nginx_result.returncode == 0
|
||||
except Exception:
|
||||
pass # Non-fatal, certificate was still generated
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": "SSL certificate regenerated successfully",
|
||||
"nginx_reloaded": nginx_reloaded,
|
||||
"output": result.stdout
|
||||
}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Certificate generation timed out"
|
||||
)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Certificate generation error: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Header Widgets API
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
class WidgetResponse(BaseModel):
|
||||
"""Header widget response model."""
|
||||
id: str
|
||||
source: str
|
||||
severity: str
|
||||
message: str
|
||||
dismissible: bool
|
||||
icon: Optional[str] = None
|
||||
action_label: Optional[str] = None
|
||||
action_url: Optional[str] = None
|
||||
created_at: str
|
||||
expires_at: Optional[str] = None
|
||||
|
||||
|
||||
@router.get("/widgets", response_model=list[WidgetResponse])
|
||||
async def get_header_widgets():
|
||||
"""Get all active header widgets.
|
||||
|
||||
Returns widgets from:
|
||||
- System managers (UPS, PM3, Updates)
|
||||
- Enabled plugins
|
||||
|
||||
Widgets are sorted by severity (error > warning > info > success).
|
||||
"""
|
||||
from ..managers.plugin_manager import get_plugin_manager
|
||||
|
||||
plugin_manager = get_plugin_manager()
|
||||
widgets = plugin_manager.get_active_widgets()
|
||||
|
||||
# Sort by severity (error first, then warning, info, success)
|
||||
severity_order = {"error": 0, "warning": 1, "info": 2, "success": 3}
|
||||
widgets.sort(key=lambda w: severity_order.get(w.severity.value, 99))
|
||||
|
||||
return [
|
||||
WidgetResponse(
|
||||
id=w.id,
|
||||
source=w.source,
|
||||
severity=w.severity.value,
|
||||
message=w.message,
|
||||
dismissible=w.dismissible,
|
||||
icon=w.icon,
|
||||
action_label=w.action_label,
|
||||
action_url=w.action_url,
|
||||
created_at=w.created_at or "",
|
||||
expires_at=w.expires_at
|
||||
)
|
||||
for w in widgets
|
||||
]
|
||||
|
||||
|
||||
@router.post("/widgets/{widget_id}/dismiss")
|
||||
async def dismiss_widget(widget_id: str):
|
||||
"""Dismiss a header widget.
|
||||
|
||||
Dismissed widgets will not reappear until the server restarts
|
||||
or the widget is explicitly re-registered.
|
||||
|
||||
Args:
|
||||
widget_id: Full widget ID (e.g., "ups.hardware_missing", "plugin.hello_world.status")
|
||||
|
||||
Returns:
|
||||
Success status
|
||||
"""
|
||||
from ..managers.plugin_manager import get_plugin_manager
|
||||
|
||||
plugin_manager = get_plugin_manager()
|
||||
success = plugin_manager.dismiss_widget(widget_id)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Widget '{widget_id}' not found or not dismissible"
|
||||
)
|
||||
|
||||
return {"success": True, "widget_id": widget_id}
|
||||
|
||||
|
||||
@router.post("/widgets/clear-dismissed")
|
||||
async def clear_dismissed_widgets():
|
||||
"""Clear all dismissed widgets, allowing them to reappear.
|
||||
|
||||
This is useful if a user wants to see previously dismissed
|
||||
notifications again.
|
||||
"""
|
||||
from ..managers.plugin_manager import get_plugin_manager
|
||||
|
||||
plugin_manager = get_plugin_manager()
|
||||
plugin_manager.clear_dismissed()
|
||||
|
||||
return {"success": True, "message": "Dismissed widgets cleared"}
|
||||
|
||||
Reference in New Issue
Block a user