feat: Implement initial Homelab Automation API v2 with new models, routes, and core architecture, including a SQLAlchemy model refactoring script.
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled

This commit is contained in:
2026-03-03 20:18:22 -05:00
parent 608f4b9197
commit c3cd7c2621
50 changed files with 2265 additions and 188 deletions
+4 -4
View File
@@ -53,8 +53,8 @@ async def verify_api_key(
Returns:
True si authentifié
"""
# Vérifier la clé API
if api_key and api_key == settings.api_key:
# Vérifier la clé API (en tant que header X-API-Key ou Bearer token)
if (api_key and api_key == settings.api_key) or (token and token == settings.api_key):
return True
# Vérifier le token JWT
@@ -86,8 +86,8 @@ async def get_current_user_optional(
Returns:
Dictionnaire utilisateur si authentifié, None sinon
"""
# Vérifier d'abord la clé API (compatibilité legacy)
if api_key and api_key == settings.api_key:
# Vérifier la clé API (en tant que header X-API-Key ou Bearer token)
if (api_key and api_key == settings.api_key) or (token and token == settings.api_key):
return {"type": "api_key", "authenticated": True}
# Vérifier le token JWT
+1 -1
View File
@@ -52,7 +52,7 @@ def create_app() -> FastAPI:
# Inclure les routers API
app.include_router(api_router, prefix="/api")
# Inclure le router WebSocket (sans préfixe /api)
app.include_router(ws_router)
+6 -6
View File
@@ -20,16 +20,16 @@ class Alert(Base):
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"))
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
category: Mapped[str] = mapped_column(String(50), nullable=False)
level: Mapped[Optional[str]] = mapped_column(String(20))
title: Mapped[Optional[str]] = mapped_column(String(255))
level: Mapped[str] = mapped_column(String(20), nullable=True)
title: Mapped[str] = mapped_column(String(255), nullable=True)
message: Mapped[str] = mapped_column(Text, nullable=False)
source: Mapped[Optional[str]] = mapped_column(String(50))
details: Mapped[Optional[dict]] = mapped_column(JSON)
source: Mapped[str] = mapped_column(String(50), nullable=True)
details: Mapped[dict] = mapped_column(JSON, nullable=True)
read_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
read_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
def __repr__(self) -> str:
+1 -1
View File
@@ -17,7 +17,7 @@ class AppSetting(Base):
)
key: Mapped[str] = mapped_column(String(100), primary_key=True)
value: Mapped[Optional[str]] = mapped_column(Text)
value: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
+3 -3
View File
@@ -16,9 +16,9 @@ class BootstrapStatus(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
status: Mapped[str] = mapped_column(String, nullable=False)
automation_user: Mapped[Optional[str]] = mapped_column(String, nullable=True)
last_attempt: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
error_message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
automation_user: Mapped[str] = mapped_column(String, nullable=True)
last_attempt: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
error_message: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
host: Mapped["Host"] = relationship("Host", back_populates="bootstrap_statuses")
+4 -4
View File
@@ -14,14 +14,14 @@ class ContainerCustomization(Base):
__tablename__ = "container_customizations"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
container_id: Mapped[str] = mapped_column(String(64), nullable=False)
icon_key: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
icon_color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
bg_color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
icon_key: Mapped[str] = mapped_column(String(100), nullable=True)
icon_color: Mapped[str] = mapped_column(String(20), nullable=True)
bg_color: Mapped[str] = mapped_column(String(20), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
+5 -5
View File
@@ -20,12 +20,12 @@ class DockerAlert(Base):
container_name: Mapped[str] = mapped_column(String(255), nullable=False)
severity: Mapped[str] = mapped_column(String(20), nullable=False, default="warning") # warning/error/critical
state: Mapped[str] = mapped_column(String(20), nullable=False, default="open") # open/closed/acknowledged
message: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
message: Mapped[str] = mapped_column(Text, nullable=True)
opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
acknowledged_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
acknowledged_by: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
last_notified_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
closed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
acknowledged_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
acknowledged_by: Mapped[str] = mapped_column(String(100), nullable=True)
last_notified_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
# Relationship to host
host: Mapped["Host"] = relationship("Host", back_populates="docker_alerts")
+7 -7
View File
@@ -19,14 +19,14 @@ class DockerContainer(Base):
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
container_id: Mapped[str] = mapped_column(String(64), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
image: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
image: Mapped[str] = mapped_column(String(255), nullable=True)
state: Mapped[str] = mapped_column(String(20), nullable=False, default="unknown") # running/exited/paused/created/dead
status: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # Up 2 hours, Exited (0) 5 minutes ago
health: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # healthy/unhealthy/starting/none
created_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
ports: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
labels: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True)
compose_project: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) # com.docker.compose.project
status: Mapped[str] = mapped_column(String(255), nullable=True) # Up 2 hours, Exited (0) 5 minutes ago
health: Mapped[str] = mapped_column(String(20), nullable=True) # healthy/unhealthy/starting/none
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
ports: Mapped[dict] = mapped_column(JSON, nullable=True)
labels: Mapped[dict] = mapped_column(JSON, nullable=True)
compose_project: Mapped[str] = mapped_column(String(255), nullable=True) # com.docker.compose.project
last_update_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+3 -3
View File
@@ -18,9 +18,9 @@ class DockerImage(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
image_id: Mapped[str] = mapped_column(String(64), nullable=False)
repo_tags: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) # ["nginx:latest", "nginx:1.25"]
size: Mapped[Optional[int]] = mapped_column(BigInteger, nullable=True)
created: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
repo_tags: Mapped[list] = mapped_column(JSON, nullable=True) # ["nginx:latest", "nginx:1.25"]
size: Mapped[int] = mapped_column(BigInteger, nullable=True)
created: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
last_update_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+3 -3
View File
@@ -18,9 +18,9 @@ class DockerVolume(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
host_id: Mapped[str] = mapped_column(String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
driver: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
mountpoint: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
scope: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # local/global
driver: Mapped[str] = mapped_column(String(50), nullable=True)
mountpoint: Mapped[str] = mapped_column(Text, nullable=True)
scope: Mapped[str] = mapped_column(String(20), nullable=True) # local/global
last_update_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now()
)
+2 -2
View File
@@ -14,13 +14,13 @@ class FavoriteContainer(Base):
__tablename__ = "favorite_containers"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
docker_container_id: Mapped[int] = mapped_column(
Integer, ForeignKey("docker_containers.id", ondelete="CASCADE"), nullable=False
)
group_id: Mapped[Optional[int]] = mapped_column(
group_id: Mapped[int] = mapped_column(
Integer, ForeignKey("favorite_groups.id", ondelete="SET NULL"), nullable=True
)
+3 -3
View File
@@ -14,11 +14,11 @@ class FavoriteGroup(Base):
__tablename__ = "favorite_groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
user_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
color: Mapped[Optional[str]] = mapped_column(String(20), nullable=True)
icon_key: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
color: Mapped[str] = mapped_column(String(20), nullable=True)
icon_key: Mapped[str] = mapped_column(String(100), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
+6 -6
View File
@@ -17,18 +17,18 @@ class Host(Base):
name: Mapped[str] = mapped_column(String, nullable=False)
ip_address: Mapped[str] = mapped_column(String, nullable=False, unique=True)
status: Mapped[str] = mapped_column(String, nullable=False, server_default=text("'unknown'"))
ansible_group: Mapped[Optional[str]] = mapped_column(String, nullable=True)
last_seen: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
ansible_group: Mapped[str] = mapped_column(String, nullable=True)
last_seen: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
reachable: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
deleted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
# Docker-related fields
docker_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
docker_version: Mapped[Optional[str]] = mapped_column(String(50), nullable=True)
docker_status: Mapped[Optional[str]] = mapped_column(String(20), nullable=True) # online/offline/error
docker_last_collect_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
docker_version: Mapped[str] = mapped_column(String(50), nullable=True)
docker_status: Mapped[str] = mapped_column(String(20), nullable=True) # online/offline/error
docker_last_collect_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
bootstrap_statuses: Mapped[List["BootstrapStatus"]] = relationship(
"BootstrapStatus", back_populates="host", cascade="all, delete-orphan"
+40 -40
View File
@@ -24,60 +24,60 @@ class HostMetrics(Base):
metric_type: Mapped[str] = mapped_column(String(50), nullable=False) # 'system_info', 'disk_usage', 'memory', etc.
# Métriques CPU
cpu_count: Mapped[Optional[int]] = mapped_column(Integer)
cpu_model: Mapped[Optional[str]] = mapped_column(String(200))
cpu_cores: Mapped[Optional[int]] = mapped_column(Integer)
cpu_threads: Mapped[Optional[int]] = mapped_column(Integer)
cpu_threads_per_core: Mapped[Optional[int]] = mapped_column(Integer)
cpu_sockets: Mapped[Optional[int]] = mapped_column(Integer)
cpu_mhz: Mapped[Optional[float]] = mapped_column(Float)
cpu_max_mhz: Mapped[Optional[float]] = mapped_column(Float)
cpu_min_mhz: Mapped[Optional[float]] = mapped_column(Float)
cpu_load_1m: Mapped[Optional[float]] = mapped_column(Float)
cpu_load_5m: Mapped[Optional[float]] = mapped_column(Float)
cpu_load_15m: Mapped[Optional[float]] = mapped_column(Float)
cpu_usage_percent: Mapped[Optional[float]] = mapped_column(Float)
cpu_temperature: Mapped[Optional[float]] = mapped_column(Float)
cpu_count: Mapped[int] = mapped_column(Integer, nullable=True)
cpu_model: Mapped[str] = mapped_column(String(200), nullable=True)
cpu_cores: Mapped[int] = mapped_column(Integer, nullable=True)
cpu_threads: Mapped[int] = mapped_column(Integer, nullable=True)
cpu_threads_per_core: Mapped[int] = mapped_column(Integer, nullable=True)
cpu_sockets: Mapped[int] = mapped_column(Integer, nullable=True)
cpu_mhz: Mapped[float] = mapped_column(Float, nullable=True)
cpu_max_mhz: Mapped[float] = mapped_column(Float, nullable=True)
cpu_min_mhz: Mapped[float] = mapped_column(Float, nullable=True)
cpu_load_1m: Mapped[float] = mapped_column(Float, nullable=True)
cpu_load_5m: Mapped[float] = mapped_column(Float, nullable=True)
cpu_load_15m: Mapped[float] = mapped_column(Float, nullable=True)
cpu_usage_percent: Mapped[float] = mapped_column(Float, nullable=True)
cpu_temperature: Mapped[float] = mapped_column(Float, nullable=True)
# Métriques mémoire
memory_total_mb: Mapped[Optional[int]] = mapped_column(Integer)
memory_used_mb: Mapped[Optional[int]] = mapped_column(Integer)
memory_free_mb: Mapped[Optional[int]] = mapped_column(Integer)
memory_usage_percent: Mapped[Optional[float]] = mapped_column(Float)
swap_total_mb: Mapped[Optional[int]] = mapped_column(Integer)
swap_used_mb: Mapped[Optional[int]] = mapped_column(Integer)
swap_usage_percent: Mapped[Optional[float]] = mapped_column(Float)
memory_total_mb: Mapped[int] = mapped_column(Integer, nullable=True)
memory_used_mb: Mapped[int] = mapped_column(Integer, nullable=True)
memory_free_mb: Mapped[int] = mapped_column(Integer, nullable=True)
memory_usage_percent: Mapped[float] = mapped_column(Float, nullable=True)
swap_total_mb: Mapped[int] = mapped_column(Integer, nullable=True)
swap_used_mb: Mapped[int] = mapped_column(Integer, nullable=True)
swap_usage_percent: Mapped[float] = mapped_column(Float, nullable=True)
# Métriques disque (stockées en JSON pour flexibilité - plusieurs disques)
disk_info: Mapped[Optional[object]] = mapped_column(JSON) # Liste des points de montage avec usage
disk_devices: Mapped[Optional[object]] = mapped_column(JSON) # Liste des disques + partitions (layout)
disk_root_total_gb: Mapped[Optional[float]] = mapped_column(Float)
disk_root_used_gb: Mapped[Optional[float]] = mapped_column(Float)
disk_root_usage_percent: Mapped[Optional[float]] = mapped_column(Float)
disk_info: Mapped[object] = mapped_column(JSON, nullable=True) # Liste des points de montage avec usage
disk_devices: Mapped[object] = mapped_column(JSON) # Liste des disques + partitions (layout, nullable=True)
disk_root_total_gb: Mapped[float] = mapped_column(Float, nullable=True)
disk_root_used_gb: Mapped[float] = mapped_column(Float, nullable=True)
disk_root_usage_percent: Mapped[float] = mapped_column(Float, nullable=True)
# Storage stacks (JSON)
lvm_info: Mapped[Optional[object]] = mapped_column(JSON)
zfs_info: Mapped[Optional[object]] = mapped_column(JSON)
lvm_info: Mapped[object] = mapped_column(JSON, nullable=True)
zfs_info: Mapped[object] = mapped_column(JSON, nullable=True)
# Stockage détaillé (JSON) - données normalisées avec métadonnées de collecte
storage_details: Mapped[Optional[dict]] = mapped_column(JSON)
storage_details: Mapped[dict] = mapped_column(JSON, nullable=True)
# Informations système
os_name: Mapped[Optional[str]] = mapped_column(String(100))
os_version: Mapped[Optional[str]] = mapped_column(String(100))
kernel_version: Mapped[Optional[str]] = mapped_column(String(100))
hostname: Mapped[Optional[str]] = mapped_column(String(200))
uptime_seconds: Mapped[Optional[int]] = mapped_column(Integer)
uptime_human: Mapped[Optional[str]] = mapped_column(String(100))
os_name: Mapped[str] = mapped_column(String(100), nullable=True)
os_version: Mapped[str] = mapped_column(String(100), nullable=True)
kernel_version: Mapped[str] = mapped_column(String(100), nullable=True)
hostname: Mapped[str] = mapped_column(String(200), nullable=True)
uptime_seconds: Mapped[int] = mapped_column(Integer, nullable=True)
uptime_human: Mapped[str] = mapped_column(String(100), nullable=True)
# Réseau (stocké en JSON pour flexibilité)
network_info: Mapped[Optional[dict]] = mapped_column(JSON)
network_info: Mapped[dict] = mapped_column(JSON, nullable=True)
# Données brutes et métadonnées
raw_data: Mapped[Optional[dict]] = mapped_column(JSON) # Données brutes du playbook
collection_source: Mapped[Optional[str]] = mapped_column(String(100)) # Nom du builtin playbook
collection_duration_ms: Mapped[Optional[int]] = mapped_column(Integer)
error_message: Mapped[Optional[str]] = mapped_column(Text)
raw_data: Mapped[dict] = mapped_column(JSON, nullable=True) # Données brutes du playbook
collection_source: Mapped[str] = mapped_column(String(100), nullable=True) # Nom du builtin playbook
collection_duration_ms: Mapped[int] = mapped_column(Integer, nullable=True)
error_message: Mapped[str] = mapped_column(Text, nullable=True)
collected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+5 -5
View File
@@ -20,12 +20,12 @@ class Log(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
level: Mapped[str] = mapped_column(String, nullable=False)
source: Mapped[Optional[str]] = mapped_column(String)
source: Mapped[str] = mapped_column(String, nullable=True)
message: Mapped[str] = mapped_column(Text, nullable=False)
details: Mapped[Optional[dict]] = mapped_column(JSON)
host_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
task_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
schedule_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
details: Mapped[dict] = mapped_column(JSON, nullable=True)
host_id: Mapped[str] = mapped_column(String, nullable=True)
task_id: Mapped[str] = mapped_column(String, nullable=True)
schedule_id: Mapped[str] = mapped_column(String, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+3 -2
View File
@@ -1,3 +1,4 @@
from __future__ import annotations
"""
Model for storing ansible-lint results per playbook.
"""
@@ -33,10 +34,10 @@ class PlaybookLintResult(Base):
execution_time_ms: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Full issues list as JSON
issues_json: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
issues_json: Mapped[str] = mapped_column(Text, nullable=True)
# Raw output from ansible-lint (for copy to clipboard)
raw_output: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
raw_output: Mapped[str] = mapped_column(Text, nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
+22 -22
View File
@@ -15,35 +15,35 @@ class Schedule(Base):
id: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
description: Mapped[Optional[str]] = mapped_column(Text)
description: Mapped[str] = mapped_column(Text, nullable=True)
playbook: Mapped[str] = mapped_column(String, nullable=False)
target_type: Mapped[Optional[str]] = mapped_column(String, default="group")
target_type: Mapped[str] = mapped_column(String, default="group", nullable=True)
target: Mapped[str] = mapped_column(String, nullable=False)
extra_vars: Mapped[Optional[Dict[str, Any]]] = mapped_column(JSON)
extra_vars: Mapped[Dict[str, Any]] = mapped_column(JSON, nullable=True)
schedule_type: Mapped[str] = mapped_column(String, nullable=False)
schedule_time: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
recurrence_type: Mapped[Optional[str]] = mapped_column(String)
recurrence_time: Mapped[Optional[str]] = mapped_column(String)
recurrence_days: Mapped[Optional[str]] = mapped_column(Text)
cron_expression: Mapped[Optional[str]] = mapped_column(String)
timezone: Mapped[Optional[str]] = mapped_column(String, default="America/Montreal")
start_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
end_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
schedule_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
recurrence_type: Mapped[str] = mapped_column(String, nullable=True)
recurrence_time: Mapped[str] = mapped_column(String, nullable=True)
recurrence_days: Mapped[str] = mapped_column(Text, nullable=True)
cron_expression: Mapped[str] = mapped_column(String, nullable=True)
timezone: Mapped[str] = mapped_column(String, default="America/Montreal", nullable=True)
start_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
end_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
tags: Mapped[Optional[str]] = mapped_column(Text)
next_run: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
last_run: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
last_status: Mapped[Optional[str]] = mapped_column(String, default="never")
retry_on_failure: Mapped[Optional[int]] = mapped_column(Integer, default=0)
timeout: Mapped[Optional[int]] = mapped_column(Integer, default=3600)
tags: Mapped[str] = mapped_column(Text, nullable=True)
next_run: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
last_run: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
last_status: Mapped[str] = mapped_column(String, default="never", nullable=True)
retry_on_failure: Mapped[int] = mapped_column(Integer, default=0, nullable=True)
timeout: Mapped[int] = mapped_column(Integer, default=3600, nullable=True)
# Type de notification: "none" (aucune), "all" (toujours), "errors" (erreurs seulement)
notification_type: Mapped[Optional[str]] = mapped_column(String, default="all")
run_count: Mapped[Optional[int]] = mapped_column(Integer, default=0)
success_count: Mapped[Optional[int]] = mapped_column(Integer, default=0)
failure_count: Mapped[Optional[int]] = mapped_column(Integer, default=0)
notification_type: Mapped[str] = mapped_column(String, default="all", nullable=True)
run_count: Mapped[int] = mapped_column(Integer, default=0, nullable=True)
success_count: Mapped[int] = mapped_column(Integer, default=0, nullable=True)
failure_count: Mapped[int] = mapped_column(Integer, default=0, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now())
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
deleted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
runs: Mapped[List["ScheduleRun"]] = relationship(
"ScheduleRun", back_populates="schedule", cascade="all, delete-orphan"
+7 -7
View File
@@ -15,18 +15,18 @@ class ScheduleRun(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
schedule_id: Mapped[str] = mapped_column(String, ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False)
task_id: Mapped[Optional[str]] = mapped_column(String, ForeignKey("tasks.id", ondelete="SET NULL"))
task_id: Mapped[str] = mapped_column(String, ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True)
status: Mapped[str] = mapped_column(String, nullable=False)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
duration: Mapped[Optional[float]] = mapped_column(Float)
hosts_impacted: Mapped[Optional[int]] = mapped_column(Integer, default=0)
error_message: Mapped[Optional[str]] = mapped_column(Text)
output: Mapped[Optional[str]] = mapped_column(Text)
completed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
duration: Mapped[float] = mapped_column(Float, nullable=True)
hosts_impacted: Mapped[int] = mapped_column(Integer, default=0, nullable=True)
error_message: Mapped[str] = mapped_column(Text, nullable=True)
output: Mapped[str] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
schedule: Mapped["Schedule"] = relationship("Schedule", back_populates="runs")
task: Mapped[Optional["Task"]] = relationship("Task", back_populates="schedule_runs")
task: Mapped["Task"] = relationship("Task", back_populates="schedule_runs")
def __repr__(self) -> str: # pragma: no cover - debug helper
return f"<ScheduleRun id={self.id} schedule_id={self.schedule_id} status={self.status}>"
+5 -5
View File
@@ -17,11 +17,11 @@ class Task(Base):
action: Mapped[str] = mapped_column(String, nullable=False)
target: Mapped[str] = mapped_column(String, nullable=False)
status: Mapped[str] = mapped_column(String, nullable=False, server_default=text("'pending'"))
playbook: Mapped[Optional[str]] = mapped_column(String)
started_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True))
error_message: Mapped[Optional[str]] = mapped_column(Text)
result_data: Mapped[Optional[dict]] = mapped_column(JSON)
playbook: Mapped[str] = mapped_column(String, nullable=True)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
error_message: Mapped[str] = mapped_column(Text, nullable=True)
result_data: Mapped[dict] = mapped_column(JSON, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
schedule_runs: Mapped[list["ScheduleRun"]] = relationship("ScheduleRun", back_populates="task")
+5 -5
View File
@@ -38,12 +38,12 @@ class TerminalCommandLog(Base):
host_id: Mapped[str] = mapped_column(
String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id: Mapped[Optional[str]] = mapped_column(
user_id: Mapped[str] = mapped_column(
String, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
# Session reference (not FK as sessions may be cleaned up)
terminal_session_id: Mapped[Optional[str]] = mapped_column(String(64), nullable=True, index=True)
terminal_session_id: Mapped[str] = mapped_column(String(64), nullable=True, index=True)
# Command data (masked/normalized version only)
command: Mapped[str] = mapped_column(Text, nullable=False)
@@ -58,11 +58,11 @@ class TerminalCommandLog(Base):
# If command was blocked (for audit - no raw command stored)
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
blocked_reason: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
blocked_reason: Mapped[str] = mapped_column(String(255), nullable=True)
# Additional metadata
username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
host_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
username: Mapped[str] = mapped_column(String(100), nullable=True)
host_name: Mapped[str] = mapped_column(String(100), nullable=True)
# Relationships
host = relationship("Host", back_populates="terminal_commands", lazy="selectin")
+5 -5
View File
@@ -57,15 +57,15 @@ class TerminalSession(Base):
host_id: Mapped[str] = mapped_column(String, nullable=False, index=True)
host_name: Mapped[str] = mapped_column(String, nullable=False)
host_ip: Mapped[str] = mapped_column(String, nullable=False)
user_id: Mapped[Optional[str]] = mapped_column(String, nullable=True)
username: Mapped[Optional[str]] = mapped_column(String, nullable=True)
user_id: Mapped[str] = mapped_column(String, nullable=True)
username: Mapped[str] = mapped_column(String, nullable=True)
# Token hash for session authentication (never store plain token)
token_hash: Mapped[str] = mapped_column(String(128), nullable=False)
# ttyd process management
ttyd_port: Mapped[int] = mapped_column(Integer, nullable=False)
ttyd_pid: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
ttyd_pid: Mapped[int] = mapped_column(Integer, nullable=True)
# Session mode: 'embedded' or 'popout'
mode: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'embedded'"))
@@ -74,7 +74,7 @@ class TerminalSession(Base):
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'active'"))
# Close reason: 'user_close', 'ttl', 'idle', 'quota', 'server_shutdown', 'client_lost', 'reused'
reason_closed: Mapped[Optional[str]] = mapped_column(String(30), nullable=True)
reason_closed: Mapped[str] = mapped_column(String(30), nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
@@ -84,7 +84,7 @@ class TerminalSession(Base):
DateTime(timezone=True), nullable=False, server_default=func.now()
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
closed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
closed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
def __repr__(self) -> str:
return f"<TerminalSession id={self.id[:8]}... host={self.host_name} status={self.status}>"
+5 -5
View File
@@ -39,7 +39,7 @@ class User(Base):
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False, index=True)
email: Mapped[Optional[str]] = mapped_column(String(255), unique=True, nullable=True)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
# Role-based access control (prepared for future)
@@ -54,7 +54,7 @@ class User(Base):
is_superuser: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
# Display name (optional)
display_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
display_name: Mapped[str] = mapped_column(String(100), nullable=True)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
@@ -68,11 +68,11 @@ class User(Base):
server_default=func.now(),
onupdate=func.now()
)
last_login: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
password_changed_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
last_login: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
password_changed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
# Soft delete support
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
deleted_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
# Relationships
terminal_commands: Mapped[List["TerminalCommandLog"]] = relationship(
+2
View File
@@ -29,6 +29,7 @@ from app.routes.lint import router as lint_router
from app.routes.terminal import router as terminal_router
from app.routes.config import router as config_router
from app.routes.favorites import router as favorites_router
from app.routes.users import router as users_router
# Router principal qui agrège tous les sous-routers
api_router = APIRouter()
@@ -56,6 +57,7 @@ api_router.include_router(lint_router, prefix="/playbooks", tags=["Lint"])
api_router.include_router(terminal_router, prefix="/terminal", tags=["Terminal"])
api_router.include_router(favorites_router, prefix="/favorites", tags=["Favorites"])
api_router.include_router(config_router, tags=["Config"])
api_router.include_router(users_router, prefix="/users", tags=["Users"])
__all__ = [
"api_router",
+28 -1
View File
@@ -1,12 +1,39 @@
from fastapi import APIRouter
import sys
import time
from datetime import datetime, timezone
from fastapi import APIRouter, Depends
from app.core.config import settings
from app.core.dependencies import verify_api_key
router = APIRouter()
_START_TIME = time.monotonic()
@router.get("/config")
async def get_app_config():
"""Récupère la configuration publique de l'application."""
return {
"debug_mode": bool(settings.debug_mode),
}
@router.get("/config/version")
async def get_app_version(api_key_valid: bool = Depends(verify_api_key)):
"""Retourne la version de l'application, Python, et l'uptime."""
uptime_seconds = int(time.monotonic() - _START_TIME)
hours, remainder = divmod(uptime_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
return {
"app_version": settings.api_version,
"app_title": settings.api_title,
"python_version": sys.version,
"python_executable": sys.executable,
"timezone": settings.scheduler_timezone,
"uptime": f"{hours}h {minutes}m {seconds}s",
"uptime_seconds": uptime_seconds,
"server_time": datetime.now(timezone.utc).isoformat(),
}
+15
View File
@@ -524,6 +524,21 @@ async def get_volumes(
)
# === Networks ===
@router.get("/hosts/{host_id}/networks")
async def get_networks(
host_id: str,
api_key_valid: bool = Depends(verify_api_key),
):
"""Liste les réseaux Docker d'un hôte."""
try:
result = await docker_actions.list_networks(host_id)
return result
except Exception as e:
raise HTTPException(status_code=500, detail=f"Erreur lors de la récupération des réseaux: {str(e)}")
# === Alerts ===
@router.get("/alerts", response_model=DockerAlertListResponse)
+37
View File
@@ -112,3 +112,40 @@ async def refresh_hosts(api_key_valid: bool = Depends(verify_api_key)):
})
return {"message": f"{len(hosts)} hôtes rechargés depuis l'inventaire Ansible"}
@router.post("/check-all")
async def check_all_hosts_health(api_key_valid: bool = Depends(verify_api_key)):
"""Exécute un health check sur tous les hôtes en parallèle."""
results = []
online = 0
offline = 0
for host in db.hosts:
health_ok = host.status == "online"
status = "online" if health_ok else "offline"
results.append({
"host": host.name,
"status": status,
"reachable": health_ok,
"os": host.os,
})
if health_ok:
online += 1
else:
offline += 1
await ws_manager.broadcast({
"type": "health_check_all",
"data": {"online": online, "offline": offline, "total": len(results)},
})
return {
"total": len(results),
"online": online,
"offline": offline,
"results": results,
"timestamp": datetime.now(timezone.utc).isoformat(),
}
+32
View File
@@ -64,3 +64,35 @@ async def download_help_pdf(api_key_valid: bool = Depends(verify_api_key)):
"Content-Disposition": "attachment; filename=homelab-automation-help.pdf"
}
)
@router.get("/catalog")
async def get_api_catalog(api_key_valid: bool = Depends(verify_api_key)):
"""
Retourne un catalogue de tous les points d'entrée de l'API en format JSON.
C'est une version simplifiée de la spec OpenAPI (Swagger).
"""
from app.routes import api_router
catalog = []
for route in api_router.routes:
# On ne liste que les routes qui ont un chemin et des méthodes (pas les Mount)
if hasattr(route, "path") and hasattr(route, "methods"):
# Exclure les routes internes ou de base si nécessaire
if any(p in route.path for p in ["/openapi.json", "/docs", "/redoc"]):
continue
catalog.append({
"path": f"/api{route.path}" if not route.path.startswith("/api") else route.path,
"methods": list(route.methods),
"summary": route.summary if hasattr(route, "summary") and route.summary else route.name,
"description": route.description if hasattr(route, "description") and route.description else "",
"tags": list(route.tags) if hasattr(route, "tags") and route.tags else []
})
return {
"title": "Homelab Automation API Catalog",
"version": "2.0.0",
"endpoints_count": len(catalog),
"endpoints": catalog
}
+36
View File
@@ -57,6 +57,42 @@ async def send_custom_notification(
return await notification_service.send_request(request)
@router.put("/config")
async def update_notification_config(
base_url: Optional[str] = None,
default_topic: Optional[str] = None,
enabled: Optional[bool] = None,
timeout: Optional[int] = None,
username: Optional[str] = None,
password: Optional[str] = None,
token: Optional[str] = None,
api_key_valid: bool = Depends(verify_api_key)
):
"""Met à jour la configuration des notifications ntfy."""
current_config = notification_service.config
new_config = NtfyConfig(
base_url=base_url if base_url is not None else current_config.base_url,
default_topic=default_topic if default_topic is not None else current_config.default_topic,
enabled=enabled if enabled is not None else current_config.enabled,
timeout=timeout if timeout is not None else current_config.timeout,
username=username if username is not None else current_config.username,
password=password if password is not None else current_config.password,
token=token if token is not None else current_config.token,
)
notification_service.reconfigure(new_config)
return {
"message": "Configuration mise à jour",
"config": {
"enabled": new_config.enabled,
"base_url": new_config.base_url,
"default_topic": new_config.default_topic,
"timeout": new_config.timeout,
"has_auth": new_config.has_auth,
}
}
@router.post("/toggle")
async def toggle_notifications(
enabled: bool,
+32
View File
@@ -16,6 +16,38 @@ from app.services import ansible_service, db
router = APIRouter()
@router.get("")
async def list_playbooks(
api_key_valid: bool = Depends(verify_api_key)
):
"""Liste tous les playbooks avec métadonnées (taille, date, etc.)."""
playbooks_dir = ansible_service.playbooks_dir
if not playbooks_dir.exists():
return {"playbooks": [], "count": 0}
result = []
for path in sorted(playbooks_dir.glob("*.yml")):
if path.name.startswith("_builtin_"):
continue
stat = path.stat()
result.append({
"filename": path.name,
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
})
for path in sorted(playbooks_dir.glob("*.yaml")):
if path.name.startswith("_builtin_"):
continue
stat = path.stat()
result.append({
"filename": path.name,
"size": stat.st_size,
"modified": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(),
})
return {"playbooks": result, "count": len(result)}
@router.get("/{filename}/content")
async def get_playbook_content(
filename: str,
+153
View File
@@ -0,0 +1,153 @@
"""
Routes API pour la gestion des utilisateurs (admin).
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.dependencies import get_db, require_admin
from app.crud.user import UserRepository
from app.schemas.auth import UserCreate, UserUpdate, UserOut
from app.services.auth_service import hash_password
router = APIRouter()
@router.get("", response_model=list[UserOut])
async def list_users(
limit: int = 100,
offset: int = 0,
include_deleted: bool = False,
user: dict = Depends(require_admin),
db_session: AsyncSession = Depends(get_db),
):
"""Liste tous les utilisateurs (admin uniquement)."""
repo = UserRepository(db_session)
users = await repo.list(limit=limit, offset=offset, include_deleted=include_deleted)
return [UserOut.model_validate(u) for u in users]
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
async def create_user(
user_data: UserCreate,
user: dict = Depends(require_admin),
db_session: AsyncSession = Depends(get_db),
):
"""Crée un nouvel utilisateur (admin uniquement)."""
repo = UserRepository(db_session)
# Vérifier l'unicité du username
existing = await repo.get_by_username(user_data.username)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Le nom d'utilisateur '{user_data.username}' est déjà utilisé",
)
# Vérifier l'unicité de l'email si fourni
if user_data.email:
existing_email = await repo.get_by_email(user_data.email)
if existing_email:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Cette adresse email est déjà utilisée",
)
hashed_password = hash_password(user_data.password)
new_user = await repo.create(
username=user_data.username,
hashed_password=hashed_password,
email=user_data.email,
display_name=user_data.display_name,
role=user_data.role,
is_active=user_data.is_active,
)
await db_session.commit()
await db_session.refresh(new_user)
return UserOut.model_validate(new_user)
@router.get("/{user_id}", response_model=UserOut)
async def get_user(
user_id: int,
user: dict = Depends(require_admin),
db_session: AsyncSession = Depends(get_db),
):
"""Récupère les détails d'un utilisateur (admin uniquement)."""
repo = UserRepository(db_session)
target_user = await repo.get(user_id)
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Utilisateur non trouvé",
)
return UserOut.model_validate(target_user)
@router.put("/{user_id}", response_model=UserOut)
async def update_user(
user_id: int,
user_data: UserUpdate,
user: dict = Depends(require_admin),
db_session: AsyncSession = Depends(get_db),
):
"""Met à jour un utilisateur (admin uniquement)."""
repo = UserRepository(db_session)
target_user = await repo.get(user_id)
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Utilisateur non trouvé",
)
update_fields = user_data.model_dump(exclude_unset=True)
if update_fields:
await repo.update(target_user, **update_fields)
await db_session.commit()
await db_session.refresh(target_user)
return UserOut.model_validate(target_user)
@router.delete("/{user_id}")
async def delete_user(
user_id: int,
permanent: bool = False,
user: dict = Depends(require_admin),
db_session: AsyncSession = Depends(get_db),
):
"""Désactive ou supprime un utilisateur (admin uniquement)."""
repo = UserRepository(db_session)
target_user = await repo.get(user_id, include_deleted=True)
if not target_user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Utilisateur non trouvé",
)
# Empêcher la suppression de soi-même
current_user_id = user.get("user_id")
if current_user_id and int(current_user_id) == user_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Vous ne pouvez pas supprimer votre propre compte",
)
if permanent:
await repo.hard_delete(user_id)
else:
await repo.soft_delete(user_id)
await db_session.commit()
return {
"message": f"Utilisateur {'supprimé définitivement' if permanent else 'désactivé'}",
"user_id": user_id,
}
+53
View File
@@ -613,5 +613,58 @@ class DockerActionsService:
}
async def list_networks(
self,
host_id: str,
) -> Dict[str, Any]:
"""List Docker networks on a host."""
import json as json_lib
host = await self._get_host_info(host_id)
try:
conn = await self._ssh_connect(host["ip"])
try:
use_sudo_state: Dict[str, Optional[bool]] = {"value": None}
result = await self._run_docker(
conn,
'docker network ls --format "{{json .}}"',
use_sudo_state,
)
networks = []
if result["success"] and result["stdout"].strip():
for line in result["stdout"].strip().splitlines():
line = line.strip()
if not line:
continue
try:
net = json_lib.loads(line)
networks.append({
"id": net.get("ID", ""),
"name": net.get("Name", ""),
"driver": net.get("Driver", ""),
"scope": net.get("Scope", ""),
})
except json_lib.JSONDecodeError:
continue
return {
"host_id": host_id,
"host_name": host["name"],
"networks": networks,
"total": len(networks),
}
finally:
conn.close()
except DockerActionError as e:
return {
"host_id": host_id,
"host_name": host.get("name", ""),
"networks": [],
"total": 0,
"error": str(e),
}
# Singleton instance
docker_actions = DockerActionsService()
+1193 -16
View File
File diff suppressed because it is too large Load Diff
+12 -5
View File
@@ -191,7 +191,7 @@ class HelpMarkdownRenderer:
def _process_accordions(self, html: str) -> str:
"""Traite les accordéons."""
pattern = r':::accordion\s+(fa-[\w-]+)\s+(\w+)\s+([^\n]+)\n(.*?):::'
pattern = r':::accordion\s+((?:fa[bs]?\s+)?fa-[\w-]+)\s+(\w+)\s+([^\n]+)\n(.*?):::'
def replace_accordion(match):
icon = match.group(1)
@@ -385,18 +385,25 @@ class HelpMarkdownRenderer:
return '\n'.join(result)
def _process_headings(self, html: str) -> str:
"""Traite les titres H3 et H4."""
"""Traite les titres H1, H3 et H4."""
# H1
html = re.sub(
r'^#\s+([^\n{]+)(?:\s*\{#[^}]+\})?\s*$',
r'<h1 class="text-3xl font-bold mb-6 text-white">\1</h1>',
html,
flags=re.MULTILINE
)
# H3
html = re.sub(
r'^### ([^\n{]+)$',
r'^###\s+([^\n{]+)(?:\s*\{#[^}]+\})?\s*$',
r'<h3 class="font-semibold mb-4 text-purple-400">\1</h3>',
html,
flags=re.MULTILINE
)
# H4
html = re.sub(
r'^#### ([^\n]+)$',
r'<h4 class="font-semibold mb-2 flex items-center gap-2"><i class="fas fa-question-circle text-gray-400"></i>\1</h4>',
r'^####\s+([^\n{]+)(?:\s*\{#[^}]+\})?\s*$',
r'<h4 class="font-semibold mb-2 flex items-center gap-2">\1</h4>',
html,
flags=re.MULTILINE
)
+45 -19
View File
@@ -110,16 +110,41 @@ def emoji_to_png_bytes(emoji: str, size: int = 32) -> Optional[bytes]:
return None
import base64
def replace_emojis_with_images(text: str) -> str:
"""Remplace les emojis par des balises <img/> pour ReportLab."""
emoji_pattern = re.compile(
"["
"\U0001F600-\U0001F64F" # emoticons
"\U0001F300-\U0001F5FF" # symbols & pictographs
"\U0001F680-\U0001F6FF" # transport & map symbols
"\U0001F1E0-\U0001F1FF" # flags
"\U00002702-\U000027B0" # dingbats
"\U000024C2-\U0001F251" # enclosed characters
"\U0001F900-\U0001F9FF" # supplemental symbols
"\U0001FA00-\U0001FA6F" # chess symbols
"\U0001FA70-\U0001FAFF" # symbols extended
"\U00002600-\U000026FF" # misc symbols
"]"
)
def repl(match):
emoji = match.group()
png_bytes = emoji_to_png_bytes(emoji, size=24)
if png_bytes:
b64_img = base64.b64encode(png_bytes).decode('ascii')
# ReportLab image tag must be valid and size specified
return f'<img src="data:image/png;base64,{b64_img}" width="12" height="12"/>'
return emoji
return emoji_pattern.sub(repl, text)
def markdown_to_pdf_bytes(markdown_content: str, title: str = "Document") -> bytes:
"""Convertit du contenu Markdown en PDF.
"""Convertit du contenu Markdown en PDF."""
# Nettoyer les caractères de dessin de boîte pour Courier
markdown_content = markdown_content.replace('├──', '|--').replace('└──', '`--').replace('│', '|')
Args:
markdown_content: Contenu au format Markdown
title: Titre du document
Returns:
Bytes du PDF généré
"""
buffer = io.BytesIO()
doc = SimpleDocTemplate(
buffer,
@@ -130,7 +155,6 @@ def markdown_to_pdf_bytes(markdown_content: str, title: str = "Document") -> byt
bottomMargin=2*cm
)
# Styles
styles = getSampleStyleSheet()
title_style = ParagraphStyle(
@@ -282,19 +306,19 @@ def markdown_to_pdf_bytes(markdown_content: str, title: str = "Document") -> byt
# Titres
if line.startswith('# '):
emojis, text = extract_leading_emojis(line[2:].strip())
display_text = f"{emojis} {text}".strip() if emojis else text
elements.append(Paragraph(display_text, h1_style))
text = line[2:].strip()
text = replace_emojis_with_images(text)
elements.append(Paragraph(text, h1_style))
elif line.startswith('## '):
emojis, text = extract_leading_emojis(line[3:].strip())
display_text = f"{emojis} {text}".strip() if emojis else text
elements.append(Paragraph(display_text, h2_style))
text = line[3:].strip()
text = replace_emojis_with_images(text)
elements.append(Paragraph(text, h2_style))
elif line.startswith('### '):
emojis, text = extract_leading_emojis(line[4:].strip())
display_text = f"{emojis} {text}".strip() if emojis else text
elements.append(Paragraph(display_text, h3_style))
text = line[4:].strip()
text = replace_emojis_with_images(text)
elements.append(Paragraph(text, h3_style))
elif line.startswith('#### '):
text = line[5:].strip()
text = replace_emojis_with_images(line[5:].strip())
elements.append(Paragraph(text, h4_style))
# Listes à puces
@@ -303,6 +327,7 @@ def markdown_to_pdf_bytes(markdown_content: str, title: str = "Document") -> byt
# Convertir le markdown basique
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
text = re.sub(r'`(.+?)`', r'<font face="Courier">\1</font>', text)
text = replace_emojis_with_images(text)
elements.append(Paragraph(f"• {text}", bullet_style))
# Listes numérotées
@@ -333,6 +358,7 @@ def markdown_to_pdf_bytes(markdown_content: str, title: str = "Document") -> byt
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
text = re.sub(r'`(.+?)`', r'<font face="Courier" color="#c7254e">\1</font>', text)
text = replace_emojis_with_images(text)
elements.append(Paragraph(text, body_style))
else: