feat: Implement comprehensive database schema with new models, CRUD operations, and documentation for host metrics, Docker management, and terminal sessions, while removing old test files.
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-05 10:16:13 -05:00
parent 121aab9d42
commit 984d06a223
72 changed files with 3781 additions and 501 deletions
+3 -3
View File
@@ -14,9 +14,9 @@ class BootstrapStatus(Base):
__tablename__ = "bootstrap_status"
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[str] = mapped_column(String, nullable=True)
host_id: Mapped[str] = mapped_column(String(50), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
status: Mapped[str] = mapped_column(String(50), nullable=False)
automation_user: Mapped[str] = mapped_column(String(50), 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())
+1 -1
View File
@@ -16,7 +16,7 @@ class ContainerCustomization(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=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)
host_id: Mapped[str] = mapped_column(String(50), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
container_id: Mapped[str] = mapped_column(String(64), nullable=False)
icon_key: Mapped[str] = mapped_column(String(100), nullable=True)
+37 -10
View File
@@ -11,7 +11,7 @@ from urllib.parse import urlparse
from alembic import command
from alembic.config import Config
from sqlalchemy import event, MetaData
from sqlalchemy import event, MetaData, text
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import declarative_base
@@ -115,8 +115,9 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
async def init_db() -> None:
"""Create all tables (mostly for dev/tests; migrations should be handled by Alembic)."""
from . import (
from . import ( # noqa: F401 — register ALL models with Base.metadata
host,
host_metrics,
task,
schedule,
schedule_run,
@@ -130,10 +131,19 @@ async def init_db() -> None:
playbook_lint,
favorite_group,
favorite_container,
) # noqa: F401
user,
terminal_session,
terminal_command_log,
bootstrap_status,
container_customization,
)
def _to_sync_database_url(db_url: str) -> str:
return db_url.replace("sqlite+aiosqlite:", "sqlite:")
if db_url.startswith("sqlite+aiosqlite:"):
return db_url.replace("sqlite+aiosqlite:", "sqlite:")
elif db_url.startswith("mysql+aiomysql:"):
return db_url.replace("mysql+aiomysql:", "mysql+pymysql:")
return db_url
def _run_alembic_upgrade() -> None:
# Try multiple locations for alembic.ini (dev vs Docker)
@@ -155,8 +165,26 @@ async def init_db() -> None:
return
try:
# For MySQL, pre-create alembic_version with VARCHAR(255) to prevent
# "Data too long for column 'version_num'" error on long revision IDs.
# SQLite doesn't enforce VARCHAR length, so this is only needed for MySQL.
sync_url = _to_sync_database_url(DATABASE_URL)
if "mysql" in sync_url:
import sqlalchemy as sa
sync_engine = sa.create_engine(sync_url)
with sync_engine.connect() as conn:
conn.execute(sa.text(
"CREATE TABLE IF NOT EXISTS alembic_version "
"(version_num VARCHAR(255) NOT NULL, "
"PRIMARY KEY (version_num))"
))
conn.commit()
sync_engine.dispose()
print("[DB] MySQL: alembic_version table ensured with VARCHAR(255)")
cfg = Config(str(alembic_ini))
cfg.set_main_option("sqlalchemy.url", _to_sync_database_url(DATABASE_URL))
cfg.set_main_option("sqlalchemy.url", sync_url)
print(f"[DB] Running Alembic upgrade to head...")
command.upgrade(cfg, "head")
print(f"[DB] Alembic upgrade completed successfully")
@@ -164,10 +192,9 @@ async def init_db() -> None:
print(f"[DB] Alembic upgrade failed: {e}")
raise
try:
await asyncio.to_thread(_run_alembic_upgrade)
except Exception as e:
print(f"[DB] Exception during Alembic migration: {e}")
print(f"[DB] Initializing database with URL: {DATABASE_URL}")
await asyncio.to_thread(_run_alembic_upgrade)
print("[DB] Ensuring all tables exist (metadata create_all)...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
print("[DB] Database initialization complete.")
+1 -1
View File
@@ -16,7 +16,7 @@ class DockerAlert(Base):
__tablename__ = "docker_alerts"
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)
host_id: Mapped[str] = mapped_column(String(50), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
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
+1 -1
View File
@@ -16,7 +16,7 @@ class DockerContainer(Base):
__tablename__ = "docker_containers"
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)
host_id: Mapped[str] = mapped_column(String(50), 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[str] = mapped_column(String(255), nullable=True)
+1 -1
View File
@@ -16,7 +16,7 @@ class DockerImage(Base):
__tablename__ = "docker_images"
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)
host_id: Mapped[str] = mapped_column(String(50), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
image_id: Mapped[str] = mapped_column(String(64), nullable=False)
repo_tags: Mapped[list] = mapped_column(JSON, nullable=True) # ["nginx:latest", "nginx:1.25"]
size: Mapped[int] = mapped_column(BigInteger, nullable=True)
+1 -1
View File
@@ -16,7 +16,7 @@ class DockerVolume(Base):
__tablename__ = "docker_volumes"
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)
host_id: Mapped[str] = mapped_column(String(50), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
driver: Mapped[str] = mapped_column(String(50), nullable=True)
mountpoint: Mapped[str] = mapped_column(Text, nullable=True)
+5 -5
View File
@@ -13,11 +13,11 @@ from .database import Base
class Host(Base):
__tablename__ = "hosts"
id: Mapped[str] = mapped_column(String, primary_key=True)
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[str] = mapped_column(String, nullable=True)
id: Mapped[str] = mapped_column(String(50), primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
ip_address: Mapped[str] = mapped_column(String(50), nullable=False, unique=True)
status: Mapped[str] = mapped_column(String(50), nullable=False, server_default=text("'unknown'"))
ansible_group: Mapped[str] = mapped_column(String(50), 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())
+1 -1
View File
@@ -20,7 +20,7 @@ class HostMetrics(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)
host_id: Mapped[str] = mapped_column(String(50), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False)
metric_type: Mapped[str] = mapped_column(String(50), nullable=False) # 'system_info', 'disk_usage', 'memory', etc.
# Métriques CPU
+5 -5
View File
@@ -19,13 +19,13 @@ class Log(Base):
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
level: Mapped[str] = mapped_column(String, nullable=False)
source: Mapped[str] = mapped_column(String, nullable=True)
level: Mapped[str] = mapped_column(String(50), nullable=False)
source: Mapped[str] = mapped_column(String(255), nullable=True)
message: Mapped[str] = mapped_column(Text, nullable=False)
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)
host_id: Mapped[str] = mapped_column(String(50), nullable=True)
task_id: Mapped[str] = mapped_column(String(50), nullable=True)
schedule_id: Mapped[str] = mapped_column(String(50), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
+12 -12
View File
@@ -13,31 +13,31 @@ from .database import Base
class Schedule(Base):
__tablename__ = "schedules"
id: Mapped[str] = mapped_column(String, primary_key=True)
name: Mapped[str] = mapped_column(String, nullable=False)
id: Mapped[str] = mapped_column(String(50), primary_key=True)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(Text, nullable=True)
playbook: Mapped[str] = mapped_column(String, nullable=False)
target_type: Mapped[str] = mapped_column(String, default="group", nullable=True)
target: Mapped[str] = mapped_column(String, nullable=False)
playbook: Mapped[str] = mapped_column(String(255), nullable=False)
target_type: Mapped[str] = mapped_column(String(50), default="group", nullable=True)
target: Mapped[str] = mapped_column(String(255), nullable=False)
extra_vars: Mapped[Dict[str, Any]] = mapped_column(JSON, nullable=True)
schedule_type: Mapped[str] = mapped_column(String, nullable=False)
schedule_type: Mapped[str] = mapped_column(String(50), nullable=False)
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_type: Mapped[str] = mapped_column(String(50), nullable=True)
recurrence_time: Mapped[str] = mapped_column(String(50), 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)
cron_expression: Mapped[str] = mapped_column(String(100), nullable=True)
timezone: Mapped[str] = mapped_column(String(100), 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[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)
last_status: Mapped[str] = mapped_column(String(50), 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[str] = mapped_column(String, default="all", nullable=True)
notification_type: Mapped[str] = mapped_column(String(50), 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)
+3 -3
View File
@@ -14,9 +14,9 @@ class ScheduleRun(Base):
__tablename__ = "schedule_runs"
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[str] = mapped_column(String, ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True)
status: Mapped[str] = mapped_column(String, nullable=False)
schedule_id: Mapped[str] = mapped_column(String(50), ForeignKey("schedules.id", ondelete="CASCADE"), nullable=False)
task_id: Mapped[str] = mapped_column(String(50), ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True)
status: Mapped[str] = mapped_column(String(50), nullable=False)
started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
completed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=True)
duration: Mapped[float] = mapped_column(Float, nullable=True)
+5 -5
View File
@@ -13,11 +13,11 @@ from .database import Base
class Task(Base):
__tablename__ = "tasks"
id: Mapped[str] = mapped_column(String, primary_key=True)
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[str] = mapped_column(String, nullable=True)
id: Mapped[str] = mapped_column(String(50), primary_key=True)
action: Mapped[str] = mapped_column(String(100), nullable=False)
target: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[str] = mapped_column(String(50), nullable=False, server_default=text("'pending'"))
playbook: Mapped[str] = mapped_column(String(255), 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)
+3 -3
View File
@@ -36,10 +36,10 @@ class TerminalCommandLog(Base):
# Foreign keys
host_id: Mapped[str] = mapped_column(
String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False, index=True
String(50), ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False, index=True
)
user_id: Mapped[str] = mapped_column(
String, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
user_id: Mapped[Optional[int]] = mapped_column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
)
# Session reference (not FK as sessions may be cleaned up)
+6 -6
View File
@@ -53,12 +53,12 @@ class TerminalSession(Base):
Index('ix_terminal_sessions_last_seen', 'last_seen_at'),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True)
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[str] = mapped_column(String, nullable=True)
username: Mapped[str] = mapped_column(String, nullable=True)
id: Mapped[str] = mapped_column(String(50), primary_key=True)
host_id: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
host_name: Mapped[str] = mapped_column(String(255), nullable=False)
host_ip: Mapped[str] = mapped_column(String(50), nullable=False)
user_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
username: Mapped[str] = mapped_column(String(50), nullable=True)
# Token hash for session authentication (never store plain token)
token_hash: Mapped[str] = mapped_column(String(128), nullable=False)