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
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:
+12
-1
@@ -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=["*"])
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user