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
+12 -1
View File
@@ -75,6 +75,9 @@ class Settings(BaseSettings):
jwt_algorithm: str = "HS256"
# === Database ===
db_engine: str = Field(
default_factory=lambda: os.environ.get("DB_ENGINE", "sqlite")
)
database_url: Optional[str] = Field(default=None)
@property
@@ -85,13 +88,21 @@ class Settings(BaseSettings):
return f"sqlite+aiosqlite:///{self.db_path}"
# === CORS ===
cors_origins: list = Field(
cors_origins: str | list = Field(
default_factory=lambda: [
o.strip() for o in os.environ.get(
"CORS_ORIGINS", "http://localhost:3000,http://localhost:8008"
).split(",") if o.strip()
]
)
@field_validator("cors_origins", mode="before")
@classmethod
def assemble_cors_origins(cls, v: object) -> list[str]:
if isinstance(v, str):
return [i.strip() for i in v.split(",") if i.strip()]
return v
cors_allow_credentials: bool = True
cors_allow_methods: list = Field(default=["*"])
cors_allow_headers: list = Field(default=["*"])
+51 -30
View File
@@ -88,37 +88,58 @@ class DockerContainerRepository:
compose_project: Optional[str] = None
) -> DockerContainer:
"""Create or update a container using SQLite upsert."""
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
# Determine if we are using MySQL or SQLite
dialect = self.session.bind.dialect.name
stmt = sqlite_insert(DockerContainer).values(
host_id=host_id,
container_id=container_id,
name=name,
image=image,
state=state,
status=status,
health=health,
created_at=created_at,
ports=ports,
labels=labels,
compose_project=compose_project,
last_update_at=datetime.utcnow()
)
stmt = stmt.on_conflict_do_update(
index_elements=['host_id', 'container_id'],
set_={
'name': stmt.excluded.name,
'image': stmt.excluded.image,
'state': stmt.excluded.state,
'status': stmt.excluded.status,
'health': stmt.excluded.health,
'created_at': stmt.excluded.created_at,
'ports': stmt.excluded.ports,
'labels': stmt.excluded.labels,
'compose_project': stmt.excluded.compose_project,
'last_update_at': stmt.excluded.last_update_at
}
)
values = {
"host_id": host_id,
"container_id": container_id,
"name": name,
"image": image,
"state": state,
"status": status,
"health": health,
"created_at": created_at,
"ports": ports,
"labels": labels,
"compose_project": compose_project,
"last_update_at": datetime.utcnow()
}
if dialect == "mysql":
from sqlalchemy.dialects.mysql import insert as mysql_insert
stmt = mysql_insert(DockerContainer).values(**values)
stmt = stmt.on_duplicate_key_update(
name=stmt.inserted.name,
image=stmt.inserted.image,
state=stmt.inserted.state,
status=stmt.inserted.status,
health=stmt.inserted.health,
created_at=stmt.inserted.created_at,
ports=stmt.inserted.ports,
labels=stmt.inserted.labels,
compose_project=stmt.inserted.compose_project,
last_update_at=stmt.inserted.last_update_at
)
else:
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
stmt = sqlite_insert(DockerContainer).values(**values)
stmt = stmt.on_conflict_do_update(
index_elements=['host_id', 'container_id'],
set_={
'name': stmt.excluded.name,
'image': stmt.excluded.image,
'state': stmt.excluded.state,
'status': stmt.excluded.status,
'health': stmt.excluded.health,
'created_at': stmt.excluded.created_at,
'ports': stmt.excluded.ports,
'labels': stmt.excluded.labels,
'compose_project': stmt.excluded.compose_project,
'last_update_at': stmt.excluded.last_update_at
}
)
await self.session.execute(stmt)
# Return the upserted container
+33 -18
View File
@@ -63,25 +63,40 @@ class DockerImageRepository:
created: Optional[datetime] = None
) -> DockerImage:
"""Create or update an image using SQLite upsert."""
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
# Determine if we are using MySQL or SQLite
dialect = self.session.bind.dialect.name
stmt = sqlite_insert(DockerImage).values(
host_id=host_id,
image_id=image_id,
repo_tags=repo_tags,
size=size,
created=created,
last_update_at=datetime.utcnow()
)
stmt = stmt.on_conflict_do_update(
index_elements=['host_id', 'image_id'],
set_={
'repo_tags': stmt.excluded.repo_tags,
'size': stmt.excluded.size,
'created': stmt.excluded.created,
'last_update_at': stmt.excluded.last_update_at
}
)
values = {
"host_id": host_id,
"image_id": image_id,
"repo_tags": repo_tags,
"size": size,
"created": created,
"last_update_at": datetime.utcnow()
}
if dialect == "mysql":
from sqlalchemy.dialects.mysql import insert as mysql_insert
stmt = mysql_insert(DockerImage).values(**values)
stmt = stmt.on_duplicate_key_update(
repo_tags=stmt.inserted.repo_tags,
size=stmt.inserted.size,
created=stmt.inserted.created,
last_update_at=stmt.inserted.last_update_at
)
else:
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
stmt = sqlite_insert(DockerImage).values(**values)
stmt = stmt.on_conflict_do_update(
index_elements=['host_id', 'image_id'],
set_={
'repo_tags': stmt.excluded.repo_tags,
'size': stmt.excluded.size,
'created': stmt.excluded.created,
'last_update_at': stmt.excluded.last_update_at
}
)
await self.session.execute(stmt)
# Return the upserted image
+33 -18
View File
@@ -56,25 +56,40 @@ class DockerVolumeRepository:
scope: Optional[str] = None
) -> DockerVolume:
"""Create or update a volume using SQLite upsert."""
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
# Determine if we are using MySQL or SQLite
dialect = self.session.bind.dialect.name
stmt = sqlite_insert(DockerVolume).values(
host_id=host_id,
name=name,
driver=driver,
mountpoint=mountpoint,
scope=scope,
last_update_at=datetime.utcnow()
)
stmt = stmt.on_conflict_do_update(
index_elements=['host_id', 'name'],
set_={
'driver': stmt.excluded.driver,
'mountpoint': stmt.excluded.mountpoint,
'scope': stmt.excluded.scope,
'last_update_at': stmt.excluded.last_update_at
}
)
values = {
"host_id": host_id,
"name": name,
"driver": driver,
"mountpoint": mountpoint,
"scope": scope,
"last_update_at": datetime.utcnow()
}
if dialect == "mysql":
from sqlalchemy.dialects.mysql import insert as mysql_insert
stmt = mysql_insert(DockerVolume).values(**values)
stmt = stmt.on_duplicate_key_update(
driver=stmt.inserted.driver,
mountpoint=stmt.inserted.mountpoint,
scope=stmt.inserted.scope,
last_update_at=stmt.inserted.last_update_at
)
else:
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
stmt = sqlite_insert(DockerVolume).values(**values)
stmt = stmt.on_conflict_do_update(
index_elements=['host_id', 'name'],
set_={
'driver': stmt.excluded.driver,
'mountpoint': stmt.excluded.mountpoint,
'scope': stmt.excluded.scope,
'last_update_at': stmt.excluded.last_update_at
}
)
await self.session.execute(stmt)
# Return the upserted volume
+3 -3
View File
@@ -89,7 +89,7 @@ class UserRepository:
role=role,
is_active=is_active,
is_superuser=is_superuser,
password_changed_at=datetime.now(timezone.utc),
password_changed_at=datetime.now(timezone.utc).replace(tzinfo=None),
)
self.session.add(user)
await self.session.flush()
@@ -106,13 +106,13 @@ class UserRepository:
async def update_password(self, user: User, hashed_password: str) -> User:
"""Update user password and timestamp."""
user.hashed_password = hashed_password
user.password_changed_at = datetime.now(timezone.utc)
user.password_changed_at = datetime.now(timezone.utc).replace(tzinfo=None)
await self.session.flush()
return user
async def update_last_login(self, user: User) -> User:
"""Update last login timestamp."""
user.last_login = datetime.now(timezone.utc)
user.last_login = datetime.now(timezone.utc).replace(tzinfo=None)
await self.session.flush()
return user
+2 -1
View File
@@ -179,7 +179,8 @@ def create_app() -> FastAPI:
# Initialiser la base de données
await init_db()
print("📦 Base de données SQLite initialisée")
engine_name = "MySQL" if settings.db_engine.lower() == "mysql" else "SQLite"
print(f"📦 Base de données {engine_name} initialisée")
# Charger les services
from app.services import (
+57 -7
View File
@@ -3741,10 +3741,10 @@
id="setup-password"
name="password"
required
minlength="6"
minlength="8"
autocomplete="new-password"
class="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition-colors pr-12"
placeholder="Minimum 6 caractères"
placeholder="Min. 8 caractères, Maj, Min, Chiffre, Spécial"
>
<button
type="button"
@@ -3765,7 +3765,7 @@
id="setup-password-confirm"
name="password_confirm"
required
minlength="6"
minlength="8"
autocomplete="new-password"
class="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white placeholder-gray-500 focus:border-purple-500 focus:ring-1 focus:ring-purple-500 transition-colors"
placeholder="Confirmez votre mot de passe"
@@ -6027,13 +6027,63 @@
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i><span>Création...</span>';
try {
const success = await dashboard.setupAdmin(username, password, email, displayName);
if (!success) {
errorText.textContent = 'Erreur lors de la création du compte';
// Direct API call to see the exact response
const apiBase = window.location.origin;
const payload = {
username,
password,
email: email || null,
display_name: displayName || null
};
console.log('[Setup] Sending setup request:', JSON.stringify(payload));
const response = await fetch(`${apiBase}/api/auth/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const responseBody = await response.text();
console.log('[Setup] Response status:', response.status);
console.log('[Setup] Response body:', responseBody);
if (!response.ok) {
let errorMessage = 'Échec de configuration';
try {
const errorData = JSON.parse(responseBody);
if (errorData.detail) {
if (Array.isArray(errorData.detail)) {
errorMessage = errorData.detail.map(err => err.msg).join(', ');
} else {
errorMessage = errorData.detail;
}
}
} catch (e) {
errorMessage = `Erreur serveur (${response.status}): ${responseBody.substring(0, 200)}`;
}
errorText.textContent = errorMessage;
errorEl.classList.remove('hidden');
return;
}
// Setup succeeded, now login
console.log('[Setup] Setup successful, attempting login...');
const loginResult = await dashboard.login(username, password);
console.log('[Setup] Login result:', loginResult);
if (!loginResult) {
// Login failed but setup succeeded — show success and redirect to login
errorEl.classList.remove('hidden');
errorEl.querySelector('i').className = 'fas fa-check-circle mr-2';
errorEl.classList.remove('text-red-400', 'bg-red-900/20', 'border-red-800');
errorEl.classList.add('text-green-400', 'bg-green-900/20', 'border-green-800');
errorText.textContent = 'Compte créé avec succès ! Redirection vers la connexion...';
setTimeout(() => { window.location.reload(); }, 2000);
}
} catch (error) {
errorText.textContent = error.message || 'Erreur de configuration';
console.error('[Setup] Error:', error);
const msg = error.message || String(error) || 'Erreur inconnue lors de la création du compte';
errorText.textContent = msg;
errorEl.classList.remove('hidden');
} finally {
submitBtn.disabled = false;
+27 -21
View File
@@ -736,30 +736,36 @@ class DashboardManager {
}
async setupAdmin(username, password, email = null, displayName = null) {
try {
const response = await fetch(`${this.apiBase}/api/auth/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password,
email: email || null,
display_name: displayName || null
})
});
const response = await fetch(`${this.apiBase}/api/auth/setup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username,
password,
email: email || null,
display_name: displayName || null
})
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Échec de configuration');
if (!response.ok) {
let errorMessage = 'Échec de configuration';
try {
const errorData = await response.json();
if (errorData.detail) {
if (Array.isArray(errorData.detail)) {
errorMessage = errorData.detail.map(err => err.msg).join(', ');
} else {
errorMessage = errorData.detail;
}
}
} catch (e) {
console.error('Could not parse error response:', e);
}
// Auto-login after setup
return await this.login(username, password);
} catch (error) {
console.error('Setup failed:', error);
this.showNotification(error.message, 'error');
return false;
throw new Error(errorMessage);
}
// Auto-login after setup
return await this.login(username, password);
}
logout() {
+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)
+4 -1
View File
@@ -26,4 +26,7 @@ pillow>=10.0.0
asyncssh>=2.14.0
slowapi>=0.1.9
cachetools>=5.3.0
jinja2>=3.1.0
jinja2>=3.1.0
# MySQL support
aiomysql>=0.2.0
cryptography>=42.0.0
+20 -12
View File
@@ -74,18 +74,26 @@ async def setup_admin(
detail="Le setup a déjà été effectué. Utilisez /login pour vous connecter."
)
# Hasher le mot de passe
hashed_password = auth_service.hash_password(user_data.password)
# Créer l'utilisateur admin
user = await repo.create(
username=user_data.username,
hashed_password=hashed_password,
email=user_data.email,
display_name=user_data.display_name,
role="admin"
)
await db_session.commit()
try:
# Hasher le mot de passe
hashed_password = auth_service.hash_password(user_data.password)
# Créer l'utilisateur admin
user = await repo.create(
username=user_data.username,
hashed_password=hashed_password,
email=user_data.email,
display_name=user_data.display_name,
role="admin"
)
await db_session.commit()
except Exception as e:
import traceback
traceback.print_exc()
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Erreur lors de la création du compte: {str(e)}"
)
return {
"message": "Compte administrateur créé avec succès",
+18
View File
@@ -0,0 +1,18 @@
import re
import os
docs_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..', 'docs'))
schema_md_path = os.path.join(docs_dir, 'database_schema.md')
mmd_path = os.path.join(docs_dir, 'erd.mmd')
with open(schema_md_path, 'r', encoding='utf-8') as f:
content = f.read()
match = re.search(r'```mermaid\n(.*?)```', content, re.DOTALL)
if match:
mermaid_code = match.group(1).strip()
with open(mmd_path, 'w', encoding='utf-8') as f:
f.write(mermaid_code)
print(f"Mermaid code extracted to {mmd_path}")
else:
print("Could not find mermaid block in database_schema.md")
+114
View File
@@ -0,0 +1,114 @@
import os
import sys
# Ensure the parent directory is in the python path to load app.models
parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
from app.models import Base
from sqlalchemy import MetaData
metadata: MetaData = Base.metadata
def get_mermaid_type(column_type):
"""Clean the column type for mermaid ERD compatibility"""
t = str(column_type).upper().split('(')[0]
return t.replace(' ', '_')
def generate_mermaid_erd(metadata: MetaData):
lines = ["erDiagram"]
# Render tables and columns
for table_name, table in sorted(metadata.tables.items()):
lines.append(f" {table_name} {{")
for column in table.columns:
col_type = get_mermaid_type(column.type)
constraints = []
if column.primary_key:
constraints.append("PK")
if column.foreign_keys:
constraints.append("FK")
# Additional constraint notes
notes = '"nullable"' if column.nullable else ""
constraints_str = " ".join(constraints)
if notes:
constraints_str += f" {notes}"
lines.append(f" {col_type} {column.name} {constraints_str}")
lines.append(" }")
# Render relationships
for table_name, table in sorted(metadata.tables.items()):
for column in table.columns:
for fk in column.foreign_keys:
target_table = fk.column.table.name
# Usually a 1-to-many from target to source in a relational schema
lines.append(f" {target_table} ||--o{{ {table_name} : \"{column.name}\"")
return "\n".join(lines)
def generate_markdown_docs(metadata: MetaData):
lines = ["# Documentation du Modèle de Données (Base de Données)\n"]
lines.append("Cette documentation a été générée automatiquement à partir des modèles SQLAlchemy.\n")
lines.append("## 1. Diagramme Entité-Association (ERD)\n")
lines.append("Le schéma ci-dessous montre l'architecture des tables et leurs relations :\n")
lines.append("```mermaid")
lines.append(generate_mermaid_erd(metadata))
lines.append("```\n")
lines.append("## 2. Dictionnaire de Données (Tables)\n")
for table_name, table in sorted(metadata.tables.items()):
lines.append(f"### Table : `{table_name}`")
if table.comment:
lines.append(f"> {table.comment}\n")
else:
lines.append("\n")
lines.append("| Colonne | Type | Clé Primaire | Clé Étrangère | Nullable | Défaut |")
lines.append("|---------|------|--------------|---------------|----------|--------|")
for column in table.columns:
col_type = str(column.type)
pk = "✅ Oui" if column.primary_key else "-"
fk_list = list(column.foreign_keys)
if fk_list:
fk_target = fk_list[0].column.table.name + "." + fk_list[0].column.name
fk = f"🔗 `{fk_target}`"
else:
fk = "-"
nullable = "Oui" if column.nullable else "**Non**"
# Format default value safely
default_val = "-"
if column.default:
if column.default.is_scalar:
default_val = f"`{column.default.arg}`"
elif column.default.is_callable:
default_val = "*(auto generate)*"
elif column.server_default:
default_val = f"`{column.server_default.arg}`"
lines.append(f"| `{column.name}` | `{col_type}` | {pk} | {fk} | {nullable} | {default_val} |")
lines.append("\n")
return "\n".join(lines)
if __name__ == "__main__":
docs_content = generate_markdown_docs(metadata)
docs_dir = os.path.join(parent_dir, 'docs')
os.makedirs(docs_dir, exist_ok=True)
output_path = os.path.join(docs_dir, 'database_schema.md')
with open(output_path, "w", encoding="utf-8") as f:
f.write(docs_content)
print(f"La documentation a été générée avec succès : {output_path}")