diff --git a/alembic/versions/0014_add_terminal_command_logs_table.py b/alembic/versions/0014_add_terminal_command_logs_table.py
new file mode 100644
index 0000000..bbf7a17
--- /dev/null
+++ b/alembic/versions/0014_add_terminal_command_logs_table.py
@@ -0,0 +1,94 @@
+"""Add terminal_command_logs table for command history
+
+Revision ID: 0014
+Revises: 0013_add_terminal_sessions_table
+Create Date: 2024-12-18
+
+This migration creates the terminal_command_logs table to store
+validated terminal commands for history and audit purposes.
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy import inspect
+
+
+# revision identifiers, used by Alembic.
+revision = '0014'
+down_revision = '0013'
+branch_labels = None
+depends_on = None
+
+
+def table_exists(table_name: str) -> bool:
+ """Check if a table exists in the database."""
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ return table_name in inspector.get_table_names()
+
+
+def index_exists(index_name: str, table_name: str) -> bool:
+ """Check if an index exists on a table."""
+ bind = op.get_bind()
+ inspector = inspect(bind)
+ indexes = inspector.get_indexes(table_name)
+ return any(idx['name'] == index_name for idx in indexes)
+
+
+def upgrade() -> None:
+ # Check if table already exists (idempotent migration)
+ if table_exists('terminal_command_logs'):
+ print("Table 'terminal_command_logs' already exists, skipping creation.")
+ else:
+ op.create_table(
+ 'terminal_command_logs',
+ sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
+ sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
+ sa.Column('host_id', sa.String(), nullable=False),
+ sa.Column('user_id', sa.String(), nullable=True),
+ sa.Column('terminal_session_id', sa.String(64), nullable=True),
+ sa.Column('command', sa.Text(), nullable=False),
+ sa.Column('command_hash', sa.String(64), nullable=False),
+ sa.Column('source', sa.String(20), server_default='terminal', nullable=False),
+ sa.Column('is_blocked', sa.Boolean(), server_default='0', nullable=False),
+ sa.Column('blocked_reason', sa.String(255), nullable=True),
+ sa.Column('username', sa.String(100), nullable=True),
+ sa.Column('host_name', sa.String(100), nullable=True),
+ sa.PrimaryKeyConstraint('id'),
+ sa.ForeignKeyConstraint(['host_id'], ['hosts.id'], ondelete='CASCADE'),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='SET NULL'),
+ )
+
+ # Create indexes for efficient querying (only if they don't exist)
+ if not index_exists('ix_terminal_command_logs_created_at', 'terminal_command_logs'):
+ op.create_index('ix_terminal_command_logs_created_at', 'terminal_command_logs', ['created_at'])
+ if not index_exists('ix_terminal_command_logs_host_id', 'terminal_command_logs'):
+ op.create_index('ix_terminal_command_logs_host_id', 'terminal_command_logs', ['host_id'])
+ if not index_exists('ix_terminal_command_logs_user_id', 'terminal_command_logs'):
+ op.create_index('ix_terminal_command_logs_user_id', 'terminal_command_logs', ['user_id'])
+ if not index_exists('ix_terminal_command_logs_terminal_session_id', 'terminal_command_logs'):
+ op.create_index('ix_terminal_command_logs_terminal_session_id', 'terminal_command_logs', ['terminal_session_id'])
+ if not index_exists('ix_terminal_command_logs_command_hash', 'terminal_command_logs'):
+ op.create_index('ix_terminal_command_logs_command_hash', 'terminal_command_logs', ['command_hash'])
+
+ # Composite indexes for common query patterns
+ if not index_exists('ix_terminal_cmd_host_created', 'terminal_command_logs'):
+ op.create_index('ix_terminal_cmd_host_created', 'terminal_command_logs', ['host_id', 'created_at'])
+ if not index_exists('ix_terminal_cmd_user_created', 'terminal_command_logs'):
+ op.create_index('ix_terminal_cmd_user_created', 'terminal_command_logs', ['user_id', 'created_at'])
+ if not index_exists('ix_terminal_cmd_host_hash', 'terminal_command_logs'):
+ op.create_index('ix_terminal_cmd_host_hash', 'terminal_command_logs', ['host_id', 'command_hash'])
+
+
+def downgrade() -> None:
+ # Drop indexes
+ op.drop_index('ix_terminal_cmd_host_hash', table_name='terminal_command_logs')
+ op.drop_index('ix_terminal_cmd_user_created', table_name='terminal_command_logs')
+ op.drop_index('ix_terminal_cmd_host_created', table_name='terminal_command_logs')
+ op.drop_index('ix_terminal_command_logs_command_hash', table_name='terminal_command_logs')
+ op.drop_index('ix_terminal_command_logs_terminal_session_id', table_name='terminal_command_logs')
+ op.drop_index('ix_terminal_command_logs_user_id', table_name='terminal_command_logs')
+ op.drop_index('ix_terminal_command_logs_host_id', table_name='terminal_command_logs')
+ op.drop_index('ix_terminal_command_logs_created_at', table_name='terminal_command_logs')
+
+ # Drop table
+ op.drop_table('terminal_command_logs')
diff --git a/alembic/versions/0015_add_terminal_session_fields.py b/alembic/versions/0015_add_terminal_session_fields.py
new file mode 100644
index 0000000..560edd3
--- /dev/null
+++ b/alembic/versions/0015_add_terminal_session_fields.py
@@ -0,0 +1,49 @@
+"""Add last_seen_at and reason_closed to terminal_sessions
+
+Revision ID: 0015
+Revises: 0014
+Create Date: 2024-12-18
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '0015'
+down_revision: Union[str, None] = '0014'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # NOTE: SQLite cannot do `ALTER TABLE ... ADD COLUMN ... DEFAULT CURRENT_TIMESTAMP`.
+ # We use batch mode to recreate the table safely.
+
+ with op.batch_alter_table('terminal_sessions', recreate='always') as batch_op:
+ batch_op.add_column(sa.Column('last_seen_at', sa.DateTime(timezone=True), nullable=True))
+ batch_op.add_column(sa.Column('reason_closed', sa.String(30), nullable=True))
+
+ # Backfill last_seen_at from created_at for existing rows
+ op.execute("UPDATE terminal_sessions SET last_seen_at = created_at WHERE last_seen_at IS NULL")
+
+ # Enforce NOT NULL + default for future inserts (recreate table again for SQLite)
+ with op.batch_alter_table('terminal_sessions', recreate='always') as batch_op:
+ batch_op.alter_column('last_seen_at', nullable=False, server_default=sa.text('CURRENT_TIMESTAMP'))
+
+ # Create indexes for efficient queries
+ op.create_index('ix_terminal_sessions_user_status', 'terminal_sessions', ['user_id', 'status'])
+ op.create_index('ix_terminal_sessions_last_seen', 'terminal_sessions', ['last_seen_at'])
+
+
+def downgrade() -> None:
+ # Drop indexes
+ op.drop_index('ix_terminal_sessions_last_seen', table_name='terminal_sessions')
+ op.drop_index('ix_terminal_sessions_user_status', table_name='terminal_sessions')
+
+ # Drop columns (batch mode for SQLite)
+ with op.batch_alter_table('terminal_sessions', recreate='always') as batch_op:
+ batch_op.drop_column('reason_closed')
+ batch_op.drop_column('last_seen_at')
diff --git a/app/crud/__init__.py b/app/crud/__init__.py
index e182dda..242fd16 100644
--- a/app/crud/__init__.py
+++ b/app/crud/__init__.py
@@ -6,6 +6,8 @@ from .log import LogRepository
from .user import UserRepository
from .alert import AlertRepository
from .app_setting import AppSettingRepository
+from .terminal_session import TerminalSessionRepository
+from .terminal_command_log import TerminalCommandLogRepository
__all__ = [
"HostRepository",
@@ -16,4 +18,6 @@ __all__ = [
"AlertRepository",
"UserRepository",
"AppSettingRepository",
+ "TerminalSessionRepository",
+ "TerminalCommandLogRepository",
]
diff --git a/app/crud/terminal_command_log.py b/app/crud/terminal_command_log.py
new file mode 100644
index 0000000..4663488
--- /dev/null
+++ b/app/crud/terminal_command_log.py
@@ -0,0 +1,312 @@
+"""
+CRUD operations for terminal command logs.
+
+Provides repository pattern for storing and retrieving terminal command history.
+"""
+import os
+from datetime import datetime, timedelta, timezone
+from typing import List, Optional
+
+from sqlalchemy import and_, delete, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from app.models.terminal_command_log import TerminalCommandLog
+
+# Configuration
+TERMINAL_COMMAND_RETENTION_DAYS = int(os.environ.get("TERMINAL_COMMAND_RETENTION_DAYS", "30"))
+
+
+class TerminalCommandLogRepository:
+ """Repository for TerminalCommandLog CRUD operations."""
+
+ def __init__(self, session: AsyncSession):
+ self.session = session
+
+ async def create(
+ self,
+ host_id: str,
+ command: str,
+ command_hash: str,
+ host_name: Optional[str] = None,
+ user_id: Optional[str] = None,
+ username: Optional[str] = None,
+ terminal_session_id: Optional[str] = None,
+ source: str = "terminal",
+ is_blocked: bool = False,
+ blocked_reason: Optional[str] = None,
+ ) -> TerminalCommandLog:
+ """Create a new command log entry."""
+ log = TerminalCommandLog(
+ host_id=host_id,
+ host_name=host_name,
+ user_id=user_id,
+ username=username,
+ terminal_session_id=terminal_session_id,
+ command=command,
+ command_hash=command_hash,
+ source=source,
+ is_blocked=is_blocked,
+ blocked_reason=blocked_reason,
+ )
+ self.session.add(log)
+ await self.session.flush()
+ return log
+
+ async def get(self, log_id: int) -> Optional[TerminalCommandLog]:
+ """Get a command log by ID."""
+ result = await self.session.execute(
+ select(TerminalCommandLog).where(TerminalCommandLog.id == log_id)
+ )
+ return result.scalar_one_or_none()
+
+ async def list_for_host(
+ self,
+ host_id: str,
+ query: Optional[str] = None,
+ limit: int = 50,
+ offset: int = 0,
+ include_blocked: bool = False,
+ ) -> List[TerminalCommandLog]:
+ """
+ List command logs for a specific host.
+
+ Args:
+ host_id: The host ID to filter by
+ query: Optional search query (filters command text)
+ limit: Maximum number of results
+ offset: Number of results to skip
+ include_blocked: Whether to include blocked commands
+ """
+ conditions = [TerminalCommandLog.host_id == host_id]
+
+ if not include_blocked:
+ conditions.append(TerminalCommandLog.is_blocked == False)
+
+ if query:
+ conditions.append(TerminalCommandLog.command.ilike(f"%{query}%"))
+
+ stmt = (
+ select(TerminalCommandLog)
+ .where(and_(*conditions))
+ .order_by(TerminalCommandLog.created_at.desc())
+ .limit(limit)
+ .offset(offset)
+ )
+
+ result = await self.session.execute(stmt)
+ return list(result.scalars().all())
+
+ async def list_for_user(
+ self,
+ user_id: str,
+ query: Optional[str] = None,
+ host_id: Optional[str] = None,
+ limit: int = 50,
+ offset: int = 0,
+ ) -> List[TerminalCommandLog]:
+ """
+ List command logs for a specific user.
+
+ Args:
+ user_id: The user ID to filter by
+ query: Optional search query
+ host_id: Optional host ID to filter by
+ limit: Maximum number of results
+ offset: Number of results to skip
+ """
+ conditions = [
+ TerminalCommandLog.user_id == user_id,
+ TerminalCommandLog.is_blocked == False,
+ ]
+
+ if host_id:
+ conditions.append(TerminalCommandLog.host_id == host_id)
+
+ if query:
+ conditions.append(TerminalCommandLog.command.ilike(f"%{query}%"))
+
+ stmt = (
+ select(TerminalCommandLog)
+ .where(and_(*conditions))
+ .order_by(TerminalCommandLog.created_at.desc())
+ .limit(limit)
+ .offset(offset)
+ )
+
+ result = await self.session.execute(stmt)
+ return list(result.scalars().all())
+
+ async def list_global(
+ self,
+ query: Optional[str] = None,
+ host_id: Optional[str] = None,
+ user_id: Optional[str] = None,
+ limit: int = 50,
+ offset: int = 0,
+ ) -> List[TerminalCommandLog]:
+ """
+ List command logs globally with optional filters.
+
+ Args:
+ query: Optional search query
+ host_id: Optional host ID to filter by
+ user_id: Optional user ID to filter by
+ limit: Maximum number of results
+ offset: Number of results to skip
+ """
+ conditions = [TerminalCommandLog.is_blocked == False]
+
+ if host_id:
+ conditions.append(TerminalCommandLog.host_id == host_id)
+
+ if user_id:
+ conditions.append(TerminalCommandLog.user_id == user_id)
+
+ if query:
+ conditions.append(TerminalCommandLog.command.ilike(f"%{query}%"))
+
+ stmt = (
+ select(TerminalCommandLog)
+ .where(and_(*conditions))
+ .order_by(TerminalCommandLog.created_at.desc())
+ .limit(limit)
+ .offset(offset)
+ )
+
+ result = await self.session.execute(stmt)
+ return list(result.scalars().all())
+
+ async def get_unique_commands_for_host(
+ self,
+ host_id: str,
+ query: Optional[str] = None,
+ limit: int = 50,
+ ) -> List[dict]:
+ """
+ Get unique commands for a host (deduped by command_hash).
+
+ Returns commands with their most recent execution time.
+ """
+ conditions = [
+ TerminalCommandLog.host_id == host_id,
+ TerminalCommandLog.is_blocked == False,
+ ]
+
+ if query:
+ conditions.append(TerminalCommandLog.command.ilike(f"%{query}%"))
+
+ # Subquery to get the max created_at for each command_hash
+ subq = (
+ select(
+ TerminalCommandLog.command_hash,
+ func.max(TerminalCommandLog.created_at).label("max_created"),
+ func.count(TerminalCommandLog.id).label("execution_count"),
+ )
+ .where(and_(*conditions))
+ .group_by(TerminalCommandLog.command_hash)
+ .subquery()
+ )
+
+ # Join to get the actual command text
+ stmt = (
+ select(
+ TerminalCommandLog.command,
+ TerminalCommandLog.command_hash,
+ subq.c.max_created,
+ subq.c.execution_count,
+ )
+ .join(subq, and_(
+ TerminalCommandLog.command_hash == subq.c.command_hash,
+ TerminalCommandLog.created_at == subq.c.max_created,
+ ))
+ .where(TerminalCommandLog.host_id == host_id)
+ .order_by(subq.c.max_created.desc())
+ .limit(limit)
+ )
+
+ result = await self.session.execute(stmt)
+ rows = result.all()
+
+ return [
+ {
+ "command": row.command,
+ "command_hash": row.command_hash,
+ "last_used": row.max_created,
+ "execution_count": row.execution_count,
+ }
+ for row in rows
+ ]
+
+ async def count_for_host(self, host_id: str) -> int:
+ """Count command logs for a host."""
+ result = await self.session.execute(
+ select(func.count(TerminalCommandLog.id))
+ .where(and_(
+ TerminalCommandLog.host_id == host_id,
+ TerminalCommandLog.is_blocked == False,
+ ))
+ )
+ return result.scalar() or 0
+
+ async def purge_old_logs(self, days: Optional[int] = None) -> int:
+ """
+ Purge logs older than the specified retention period.
+
+ Args:
+ days: Number of days to retain (defaults to TERMINAL_COMMAND_RETENTION_DAYS)
+
+ Returns:
+ Number of logs deleted
+ """
+ if days is None:
+ days = TERMINAL_COMMAND_RETENTION_DAYS
+
+ cutoff = datetime.now(timezone.utc) - timedelta(days=days)
+
+ # First count how many will be deleted
+ count_result = await self.session.execute(
+ select(func.count(TerminalCommandLog.id))
+ .where(TerminalCommandLog.created_at < cutoff)
+ )
+ count = count_result.scalar() or 0
+
+ # Delete old logs
+ await self.session.execute(
+ delete(TerminalCommandLog)
+ .where(TerminalCommandLog.created_at < cutoff)
+ )
+
+ await self.session.flush()
+ return count
+
+ async def delete_for_host(self, host_id: str) -> int:
+ """Delete all command logs for a host."""
+ count_result = await self.session.execute(
+ select(func.count(TerminalCommandLog.id))
+ .where(TerminalCommandLog.host_id == host_id)
+ )
+ count = count_result.scalar() or 0
+
+ await self.session.execute(
+ delete(TerminalCommandLog)
+ .where(TerminalCommandLog.host_id == host_id)
+ )
+
+ await self.session.flush()
+ return count
+
+ async def delete_for_session(self, session_id: str) -> int:
+ """Delete all command logs for a terminal session."""
+ count_result = await self.session.execute(
+ select(func.count(TerminalCommandLog.id))
+ .where(TerminalCommandLog.terminal_session_id == session_id)
+ )
+ count = count_result.scalar() or 0
+
+ await self.session.execute(
+ delete(TerminalCommandLog)
+ .where(TerminalCommandLog.terminal_session_id == session_id)
+ )
+
+ await self.session.flush()
+ return count
diff --git a/app/crud/terminal_session.py b/app/crud/terminal_session.py
index 6ebac7e..7f57674 100644
--- a/app/crud/terminal_session.py
+++ b/app/crud/terminal_session.py
@@ -1,13 +1,23 @@
"""
CRUD operations for terminal sessions.
"""
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
from typing import List, Optional
-from sqlalchemy import select, update, and_
+from sqlalchemy import select, update, and_, or_
from sqlalchemy.ext.asyncio import AsyncSession
-from app.models.terminal_session import TerminalSession
+from app.models.terminal_session import (
+ TerminalSession,
+ SESSION_STATUS_ACTIVE,
+ SESSION_STATUS_CLOSED,
+ SESSION_STATUS_EXPIRED,
+ SESSION_STATUS_CLOSING,
+ CLOSE_REASON_USER,
+ CLOSE_REASON_TTL,
+ CLOSE_REASON_IDLE,
+ CLOSE_REASON_CLIENT_LOST,
+)
class TerminalSessionRepository:
@@ -31,6 +41,7 @@ class TerminalSessionRepository:
mode: str = "embedded",
) -> TerminalSession:
"""Create a new terminal session."""
+ now = datetime.now(timezone.utc)
session = TerminalSession(
id=id,
host_id=host_id,
@@ -42,8 +53,9 @@ class TerminalSessionRepository:
ttyd_port=ttyd_port,
ttyd_pid=ttyd_pid,
mode=mode,
- status="active",
+ status=SESSION_STATUS_ACTIVE,
expires_at=expires_at,
+ last_seen_at=now,
)
self.session.add(session)
await self.session.flush()
@@ -63,13 +75,46 @@ class TerminalSessionRepository:
select(TerminalSession).where(
and_(
TerminalSession.id == session_id,
- TerminalSession.status == "active",
+ TerminalSession.status == SESSION_STATUS_ACTIVE,
TerminalSession.expires_at > now
)
)
)
return result.scalar_one_or_none()
+ async def find_reusable_session(
+ self,
+ user_id: str,
+ host_id: str,
+ mode: str,
+ idle_timeout_seconds: int = 120
+ ) -> Optional[TerminalSession]:
+ """
+ Find an existing active session that can be reused.
+
+ A session is reusable if:
+ - Same user, host, and mode
+ - Status is 'active'
+ - Not expired
+ - Last seen within idle timeout (considered "healthy")
+ """
+ now = datetime.now(timezone.utc)
+ min_last_seen = now - timedelta(seconds=idle_timeout_seconds)
+
+ result = await self.session.execute(
+ select(TerminalSession).where(
+ and_(
+ TerminalSession.user_id == user_id,
+ TerminalSession.host_id == host_id,
+ TerminalSession.mode == mode,
+ TerminalSession.status == SESSION_STATUS_ACTIVE,
+ TerminalSession.expires_at > now,
+ TerminalSession.last_seen_at >= min_last_seen
+ )
+ ).order_by(TerminalSession.created_at.desc())
+ )
+ return result.scalar_one_or_none()
+
async def list_active_for_user(self, user_id: str) -> List[TerminalSession]:
"""List all active sessions for a user."""
now = datetime.now(timezone.utc)
@@ -77,7 +122,20 @@ class TerminalSessionRepository:
select(TerminalSession).where(
and_(
TerminalSession.user_id == user_id,
- TerminalSession.status == "active",
+ TerminalSession.status == SESSION_STATUS_ACTIVE,
+ TerminalSession.expires_at > now
+ )
+ ).order_by(TerminalSession.created_at.desc())
+ )
+ return list(result.scalars().all())
+
+ async def list_all_active(self) -> List[TerminalSession]:
+ """List all active sessions (for admin/GC)."""
+ now = datetime.now(timezone.utc)
+ result = await self.session.execute(
+ select(TerminalSession).where(
+ and_(
+ TerminalSession.status == SESSION_STATUS_ACTIVE,
TerminalSession.expires_at > now
)
).order_by(TerminalSession.created_at.desc())
@@ -90,23 +148,63 @@ class TerminalSessionRepository:
return len(sessions)
async def list_expired(self) -> List[TerminalSession]:
- """List all expired but not yet closed sessions."""
+ """List all expired but not yet closed sessions (TTL exceeded)."""
now = datetime.now(timezone.utc)
result = await self.session.execute(
select(TerminalSession).where(
and_(
- TerminalSession.status == "active",
+ TerminalSession.status == SESSION_STATUS_ACTIVE,
TerminalSession.expires_at <= now
)
)
)
return list(result.scalars().all())
+ async def list_idle(self, idle_timeout_seconds: int) -> List[TerminalSession]:
+ """List all idle sessions (no heartbeat within timeout)."""
+ now = datetime.now(timezone.utc)
+ idle_cutoff = now - timedelta(seconds=idle_timeout_seconds)
+
+ result = await self.session.execute(
+ select(TerminalSession).where(
+ and_(
+ TerminalSession.status == SESSION_STATUS_ACTIVE,
+ TerminalSession.last_seen_at < idle_cutoff
+ )
+ )
+ )
+ return list(result.scalars().all())
+
+ async def list_stale_sessions(self, ttl_seconds: int, idle_timeout_seconds: int) -> List[TerminalSession]:
+ """
+ List all sessions that should be cleaned up.
+
+ Returns sessions that are either:
+ - Expired (past TTL)
+ - Idle (no heartbeat within idle timeout)
+ """
+ now = datetime.now(timezone.utc)
+ idle_cutoff = now - timedelta(seconds=idle_timeout_seconds)
+
+ result = await self.session.execute(
+ select(TerminalSession).where(
+ and_(
+ TerminalSession.status == SESSION_STATUS_ACTIVE,
+ or_(
+ TerminalSession.expires_at <= now,
+ TerminalSession.last_seen_at < idle_cutoff
+ )
+ )
+ )
+ )
+ return list(result.scalars().all())
+
async def update_status(
self,
session_id: str,
status: str,
- closed_at: Optional[datetime] = None
+ closed_at: Optional[datetime] = None,
+ reason_closed: Optional[str] = None
) -> Optional[TerminalSession]:
"""Update session status."""
session = await self.get(session_id)
@@ -114,34 +212,67 @@ class TerminalSessionRepository:
session.status = status
if closed_at:
session.closed_at = closed_at
+ if reason_closed:
+ session.reason_closed = reason_closed
await self.session.flush()
return session
- async def close_session(self, session_id: str) -> Optional[TerminalSession]:
- """Close a terminal session."""
+ async def update_last_seen(self, session_id: str) -> Optional[TerminalSession]:
+ """Update last_seen_at timestamp (heartbeat)."""
+ session = await self.get(session_id)
+ if session and session.status == SESSION_STATUS_ACTIVE:
+ session.last_seen_at = datetime.now(timezone.utc)
+ await self.session.flush()
+ return session
+
+ async def close_session(
+ self,
+ session_id: str,
+ reason: str = CLOSE_REASON_USER
+ ) -> Optional[TerminalSession]:
+ """Close a terminal session with reason."""
return await self.update_status(
session_id,
- status="closed",
- closed_at=datetime.now(timezone.utc)
+ status=SESSION_STATUS_CLOSED,
+ closed_at=datetime.now(timezone.utc),
+ reason_closed=reason
)
async def mark_expired(self, session_id: str) -> Optional[TerminalSession]:
- """Mark a session as expired."""
+ """Mark a session as expired (TTL)."""
return await self.update_status(
session_id,
- status="expired",
- closed_at=datetime.now(timezone.utc)
+ status=SESSION_STATUS_EXPIRED,
+ closed_at=datetime.now(timezone.utc),
+ reason_closed=CLOSE_REASON_TTL
+ )
+
+ async def mark_idle(self, session_id: str) -> Optional[TerminalSession]:
+ """Mark a session as expired due to idle timeout."""
+ return await self.update_status(
+ session_id,
+ status=SESSION_STATUS_EXPIRED,
+ closed_at=datetime.now(timezone.utc),
+ reason_closed=CLOSE_REASON_IDLE
+ )
+
+ async def mark_client_lost(self, session_id: str) -> Optional[TerminalSession]:
+ """Mark a session as closed due to client disconnect."""
+ return await self.update_status(
+ session_id,
+ status=SESSION_STATUS_CLOSED,
+ closed_at=datetime.now(timezone.utc),
+ reason_closed=CLOSE_REASON_CLIENT_LOST
)
async def cleanup_old_sessions(self, days: int = 7) -> int:
"""Delete sessions older than specified days."""
- from datetime import timedelta
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
result = await self.session.execute(
select(TerminalSession).where(
and_(
- TerminalSession.status.in_(["closed", "expired", "error"]),
+ TerminalSession.status.in_([SESSION_STATUS_CLOSED, SESSION_STATUS_EXPIRED, "error"]),
TerminalSession.created_at < cutoff
)
)
@@ -155,18 +286,33 @@ class TerminalSessionRepository:
await self.session.flush()
return count
- async def close_all_active(self) -> int:
+ async def close_all_active(self, reason: str = CLOSE_REASON_USER) -> int:
"""Close all active sessions (for cleanup/reset)."""
now = datetime.now(timezone.utc)
result = await self.session.execute(
- select(TerminalSession).where(TerminalSession.status == "active")
+ select(TerminalSession).where(TerminalSession.status == SESSION_STATUS_ACTIVE)
)
sessions = list(result.scalars().all())
count = len(sessions)
for session in sessions:
- session.status = "closed"
+ session.status = SESSION_STATUS_CLOSED
session.closed_at = now
+ session.reason_closed = reason
await self.session.flush()
return count
+
+ async def get_oldest_active_for_user(self, user_id: str) -> Optional[TerminalSession]:
+ """Get the oldest active session for a user (for auto-close)."""
+ now = datetime.now(timezone.utc)
+ result = await self.session.execute(
+ select(TerminalSession).where(
+ and_(
+ TerminalSession.user_id == user_id,
+ TerminalSession.status == SESSION_STATUS_ACTIVE,
+ TerminalSession.expires_at > now
+ )
+ ).order_by(TerminalSession.created_at.asc())
+ )
+ return result.scalars().first()
diff --git a/app/factory.py b/app/factory.py
index df7ae87..07b16d3 100644
--- a/app/factory.py
+++ b/app/factory.py
@@ -193,6 +193,12 @@ def create_app() -> FastAPI:
replace_existing=True
)
print("π³ Docker monitoring jobs scheduled")
+
+ # Start terminal session GC task
+ from app.services.terminal_service import terminal_service
+ terminal_service.set_db_session_factory(async_session_maker)
+ await terminal_service.start_gc_task()
+ print("π₯οΈ Terminal session GC task started")
# Afficher l'Γ©tat du service de notification
ntfy_status = "activΓ©" if notification_service.enabled else "dΓ©sactivΓ©"
@@ -224,11 +230,16 @@ def create_app() -> FastAPI:
print("π ArrΓͺt de l'application...")
from app.services import scheduler_service, notification_service
+ from app.services.terminal_service import terminal_service
# ArrΓͺter la capture des logs console
from app.services import console_log_service
console_log_service.stop_capture()
+ # Stop terminal session GC task
+ await terminal_service.stop_gc_task()
+ print("π₯οΈ Terminal session GC task stopped")
+
# ArrΓͺter le scheduler
scheduler_service.shutdown()
diff --git a/app/index.html b/app/index.html
index bf528c0..69a0987 100644
--- a/app/index.html
+++ b/app/index.html
@@ -3154,6 +3154,210 @@
color: #ef4444;
}
+ /* Terminal History Panel */
+ .terminal-history-panel {
+ background: #1e1e2e;
+ border-bottom: 1px solid #374151;
+ max-height: 300px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .terminal-history-header {
+ padding: 0.75rem 1rem;
+ background: #1a1a2e;
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ flex-shrink: 0;
+ }
+
+ .terminal-history-search {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ background: #0f0f1a;
+ border: 1px solid #374151;
+ border-radius: 0.5rem;
+ padding: 0 0.75rem;
+ gap: 0.5rem;
+ }
+
+ .terminal-history-search i {
+ color: #6b7280;
+ }
+
+ .terminal-history-search input {
+ flex: 1;
+ background: transparent;
+ border: none;
+ outline: none;
+ color: #fff;
+ padding: 0.5rem 0;
+ font-size: 0.875rem;
+ }
+
+ .terminal-history-search input::placeholder {
+ color: #6b7280;
+ }
+
+ .terminal-history-clear-search {
+ background: none;
+ border: none;
+ color: #6b7280;
+ cursor: pointer;
+ padding: 0.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ .terminal-history-clear-search:hover {
+ color: #9ca3af;
+ }
+
+ .terminal-history-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ .terminal-history-scope {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ font-size: 0.75rem;
+ color: #9ca3af;
+ cursor: pointer;
+ }
+
+ .terminal-history-scope input {
+ width: 1rem;
+ height: 1rem;
+ accent-color: #7c3aed;
+ }
+
+ .terminal-history-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 0.5rem;
+ }
+
+ .terminal-history-item {
+ display: flex;
+ align-items: center;
+ padding: 0.5rem 0.75rem;
+ border-radius: 0.375rem;
+ cursor: pointer;
+ transition: background 0.15s;
+ gap: 0.75rem;
+ }
+
+ .terminal-history-item:hover {
+ background: rgba(124, 58, 237, 0.15);
+ }
+
+ .terminal-history-cmd {
+ flex: 1;
+ min-width: 0;
+ }
+
+ .terminal-history-cmd code {
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
+ font-size: 0.8125rem;
+ color: #a5b4fc;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ display: block;
+ }
+
+ .terminal-history-meta {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex-shrink: 0;
+ }
+
+ .terminal-history-time {
+ font-size: 0.6875rem;
+ color: #6b7280;
+ }
+
+ .terminal-history-count {
+ font-size: 0.625rem;
+ color: #7c3aed;
+ background: rgba(124, 58, 237, 0.2);
+ padding: 0.125rem 0.375rem;
+ border-radius: 9999px;
+ }
+
+ .terminal-history-host {
+ font-size: 0.6875rem;
+ color: #4ade80;
+ background: rgba(74, 222, 128, 0.1);
+ padding: 0.125rem 0.375rem;
+ border-radius: 0.25rem;
+ }
+
+ .terminal-history-actions-inline {
+ display: flex;
+ gap: 0.25rem;
+ opacity: 0;
+ transition: opacity 0.15s;
+ }
+
+ .terminal-history-item:hover .terminal-history-actions-inline {
+ opacity: 1;
+ }
+
+ .terminal-history-action {
+ background: rgba(55, 65, 81, 0.5);
+ border: none;
+ color: #9ca3af;
+ padding: 0.375rem;
+ border-radius: 0.25rem;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 0.75rem;
+ }
+
+ .terminal-history-action:hover {
+ background: rgba(124, 58, 237, 0.3);
+ color: #fff;
+ }
+
+ .terminal-history-empty {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem;
+ color: #6b7280;
+ gap: 0.5rem;
+ }
+
+ .terminal-history-empty i {
+ font-size: 1.5rem;
+ }
+
+ .terminal-history-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem;
+ color: #9ca3af;
+ gap: 0.5rem;
+ }
+
+ #terminalHistoryBtn.active {
+ background: #7c3aed;
+ color: #fff;
+ }
+
/* Mobile responsive */
@media (max-width: 768px) {
.terminal-drawer-panel {
@@ -3166,6 +3370,85 @@
display: none;
}
}
+
+ /* =====================================================
+ Session Limit Modal Styles
+ ===================================================== */
+ .session-limit-modal {
+ max-width: 500px;
+ width: 90%;
+ }
+
+ .session-limit-message {
+ color: #e5e7eb;
+ margin-bottom: 1rem;
+ }
+
+ .session-limit-reuse {
+ background: rgba(34, 197, 94, 0.1);
+ border: 1px solid rgba(34, 197, 94, 0.3);
+ border-radius: 0.5rem;
+ padding: 1rem;
+ margin-bottom: 1rem;
+ }
+
+ .session-limit-reuse p {
+ color: #4ade80;
+ margin-bottom: 0.75rem;
+ }
+
+ .session-limit-list {
+ margin-bottom: 1rem;
+ }
+
+ .session-limit-list h4 {
+ color: #9ca3af;
+ font-size: 0.875rem;
+ margin-bottom: 0.75rem;
+ }
+
+ .session-limit-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem;
+ background: rgba(55, 65, 81, 0.5);
+ border-radius: 0.5rem;
+ margin-bottom: 0.5rem;
+ transition: opacity 0.3s ease;
+ }
+
+ .session-limit-item-info {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ }
+
+ .session-limit-host {
+ color: #fff;
+ font-weight: 500;
+ }
+
+ .session-limit-mode {
+ color: #9ca3af;
+ font-size: 0.75rem;
+ padding: 0.125rem 0.5rem;
+ background: rgba(107, 114, 128, 0.3);
+ border-radius: 0.25rem;
+ }
+
+ .session-limit-age {
+ color: #9ca3af;
+ font-size: 0.75rem;
+ }
+
+ .session-limit-actions {
+ display: flex;
+ justify-content: flex-end;
+ gap: 0.5rem;
+ padding-top: 1rem;
+ border-top: 1px solid #374151;
+ }
diff --git a/app/main.js b/app/main.js
index df19b1e..8ff4c45 100644
--- a/app/main.js
+++ b/app/main.js
@@ -89,6 +89,12 @@ class DashboardManager {
this.terminalSession = null;
this.terminalDrawerOpen = false;
this.terminalFeatureAvailable = false;
+ this.terminalCommandHistory = [];
+ this.terminalHistorySearchQuery = '';
+ this.terminalHistoryLoading = false;
+ this.terminalHeartbeatInterval = null;
+ this.terminalHeartbeatIntervalMs = 15000; // 15 seconds
+ this.terminalOpeningPromise = null; // Prevent double-click
// Configuration: collecte mΓ©triques
this.metricsCollectionInterval = 'off';
@@ -109,6 +115,7 @@ class DashboardManager {
this.setupScrollAnimations();
this.startAnimations();
this.loadThemePreference();
+ this.setupTerminalCleanupHandlers();
// Check authentication status first
const authOk = await this.checkAuthStatus();
@@ -10108,6 +10115,11 @@ class DashboardManager {
}
async openTerminal(hostId, hostName, hostIp) {
+ // Anti-double-click: reuse pending promise
+ if (this.terminalOpeningPromise) {
+ return this.terminalOpeningPromise;
+ }
+
// Check if terminal feature is available
if (!this.terminalFeatureAvailable) {
const status = await this.checkTerminalFeatureStatus();
@@ -10115,26 +10127,59 @@ class DashboardManager {
this.showNotification('Terminal SSH non disponible: ttyd n\'est pas installΓ©', 'error');
return;
}
+ // Store heartbeat interval from server config
+ if (status.heartbeat_interval_seconds) {
+ this.terminalHeartbeatIntervalMs = status.heartbeat_interval_seconds * 1000;
+ }
}
// Show loading state
this.showTerminalDrawer(hostName, hostIp, true);
- try {
- const session = await this.apiCall(`/api/terminal/${hostId}/terminal-sessions`, {
- method: 'POST',
- body: JSON.stringify({ mode: 'embedded' })
- });
- this.terminalSession = session;
-
- // Update drawer with terminal iframe
- this.updateTerminalDrawer(session);
-
- } catch (e) {
- console.error('Failed to open terminal:', e);
- this.showNotification(`Erreur terminal: ${e.message}`, 'error');
- this.closeTerminalDrawer();
- }
+ // Create promise to prevent double-click
+ this.terminalOpeningPromise = (async () => {
+ try {
+ const response = await this.apiCall(`/api/terminal/${hostId}/terminal-sessions`, {
+ method: 'POST',
+ body: JSON.stringify({ mode: 'embedded' })
+ });
+
+ // Check if this is a session limit error (429 with rich response)
+ if (response.error === 'SESSION_LIMIT') {
+ this.closeTerminalDrawer();
+ this.showSessionLimitModal(response, hostId, hostName, hostIp);
+ return;
+ }
+
+ this.terminalSession = response;
+
+ // Update drawer with terminal iframe
+ this.updateTerminalDrawer(response);
+
+ // Start heartbeat
+ this.startTerminalHeartbeat();
+
+ // Log if session was reused
+ if (response.reused) {
+ console.log('Terminal session reused:', response.session_id);
+ }
+
+ } catch (e) {
+ console.error('Failed to open terminal:', e);
+ // Check if error response contains session limit info
+ if (e.response && e.response.error === 'SESSION_LIMIT') {
+ this.closeTerminalDrawer();
+ this.showSessionLimitModal(e.response, hostId, hostName, hostIp);
+ } else {
+ this.showNotification(`Erreur terminal: ${e.message}`, 'error');
+ this.closeTerminalDrawer();
+ }
+ } finally {
+ this.terminalOpeningPromise = null;
+ }
+ })();
+
+ return this.terminalOpeningPromise;
}
async openTerminalPopout(hostId, hostName, hostIp) {
@@ -10201,6 +10246,9 @@ class DashboardManager {
Connexion...
+
+
+
+
${loadingContent}
@@ -10314,6 +10391,9 @@ class DashboardManager {
this._terminalTimerInterval = null;
}
+ // Stop heartbeat
+ this.stopTerminalHeartbeat();
+
// Remove escape handler
if (this._terminalEscHandler) {
document.removeEventListener('keydown', this._terminalEscHandler);
@@ -10335,6 +10415,211 @@ class DashboardManager {
this.terminalDrawerOpen = false;
}
+ // ===== TERMINAL CLEANUP HANDLERS =====
+
+ setupTerminalCleanupHandlers() {
+ // Handle page unload (browser close, navigation away)
+ window.addEventListener('beforeunload', () => {
+ this.sendTerminalCloseBeacon();
+ });
+
+ // Handle page hide (mobile tab switch, etc.)
+ window.addEventListener('pagehide', () => {
+ this.sendTerminalCloseBeacon();
+ });
+
+ // Handle visibility change (tab hidden)
+ document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'hidden') {
+ // Stop heartbeat when tab is hidden
+ this.stopTerminalHeartbeat();
+ } else if (document.visibilityState === 'visible' && this.terminalSession && this.terminalDrawerOpen) {
+ // Resume heartbeat when tab becomes visible again
+ this.startTerminalHeartbeat();
+ }
+ });
+ }
+
+ sendTerminalCloseBeacon() {
+ if (!this.terminalSession) return;
+
+ const sessionId = this.terminalSession.session_id;
+ const url = `${this.apiBase}/api/terminal/sessions/${sessionId}/close-beacon`;
+
+ // Use sendBeacon for reliable delivery during page unload
+ if (navigator.sendBeacon) {
+ const blob = new Blob([JSON.stringify({ reason: 'client_close' })], { type: 'application/json' });
+ navigator.sendBeacon(url, blob);
+ console.log('Terminal close beacon sent via sendBeacon');
+ } else {
+ // Fallback: synchronous XHR (blocking, but ensures delivery)
+ try {
+ const xhr = new XMLHttpRequest();
+ xhr.open('POST', url, false); // synchronous
+ xhr.setRequestHeader('Content-Type', 'application/json');
+ xhr.send(JSON.stringify({ reason: 'client_close' }));
+ console.log('Terminal close beacon sent via XHR');
+ } catch (e) {
+ console.warn('Failed to send terminal close beacon:', e);
+ }
+ }
+
+ // Clear session reference
+ this.terminalSession = null;
+ }
+
+ // ===== TERMINAL HEARTBEAT =====
+
+ startTerminalHeartbeat() {
+ this.stopTerminalHeartbeat(); // Clear any existing
+
+ if (!this.terminalSession) return;
+
+ this.terminalHeartbeatInterval = setInterval(async () => {
+ if (!this.terminalSession || !this.terminalDrawerOpen) {
+ this.stopTerminalHeartbeat();
+ return;
+ }
+
+ try {
+ await this.apiCall(`/api/terminal/sessions/${this.terminalSession.session_id}/heartbeat`, {
+ method: 'POST'
+ });
+ } catch (e) {
+ console.warn('Terminal heartbeat failed:', e);
+ // Session might be expired - don't stop heartbeat, let server handle it
+ }
+ }, this.terminalHeartbeatIntervalMs);
+
+ console.log('Terminal heartbeat started');
+ }
+
+ stopTerminalHeartbeat() {
+ if (this.terminalHeartbeatInterval) {
+ clearInterval(this.terminalHeartbeatInterval);
+ this.terminalHeartbeatInterval = null;
+ console.log('Terminal heartbeat stopped');
+ }
+ }
+
+ // ===== TERMINAL SESSION LIMIT MODAL =====
+
+ showSessionLimitModal(limitError, targetHostId, targetHostName, targetHostIp) {
+ // Remove existing modal if any
+ const existingModal = document.getElementById('sessionLimitModal');
+ if (existingModal) existingModal.remove();
+
+ const sessionsHtml = limitError.active_sessions.map(s => `
+
+
+ ${this.escapeHtml(s.host_name)}
+ ${s.mode}
+ ${this.formatDuration(s.age_seconds)}
+
+
+
+ `).join('');
+
+ const canReuseHtml = limitError.can_reuse ? `
+
+
Une session existe dΓ©jΓ pour cet hΓ΄te. Vous pouvez la rΓ©utiliser.
+
+
+ ` : '';
+
+ const modal = document.createElement('div');
+ modal.id = 'sessionLimitModal';
+ modal.className = 'modal-overlay';
+ modal.innerHTML = `
+
+
+
+
+ Vous avez atteint la limite de ${limitError.max_active} sessions actives.
+ Fermez une session existante pour en ouvrir une nouvelle.
+
+ ${canReuseHtml}
+
+
Sessions actives (${limitError.current_count}/${limitError.max_active})
+ ${sessionsHtml}
+
+
+
+
+
+
+ `;
+
+ document.body.appendChild(modal);
+ requestAnimationFrame(() => modal.classList.add('show'));
+ }
+
+ closeSessionLimitModal() {
+ const modal = document.getElementById('sessionLimitModal');
+ if (modal) {
+ modal.classList.remove('show');
+ setTimeout(() => modal.remove(), 300);
+ }
+ }
+
+ async closeSessionFromModal(sessionId) {
+ try {
+ await this.apiCall(`/api/terminal/sessions/${sessionId}`, { method: 'DELETE' });
+
+ // Remove item from modal
+ const item = document.querySelector(`[data-session-id="${sessionId}"]`);
+ if (item) {
+ item.style.opacity = '0.5';
+ item.querySelector('button').disabled = true;
+ item.querySelector('button').innerHTML = ' FermΓ©e';
+ }
+
+ this.showNotification('Session fermΓ©e', 'success');
+ } catch (e) {
+ this.showNotification(`Erreur: ${e.message}`, 'error');
+ }
+ }
+
+ async closeOldestSessionFromModal(targetHostId, targetHostName, targetHostIp) {
+ try {
+ // Get current sessions
+ const sessions = await this.apiCall('/api/terminal/sessions');
+ if (sessions.sessions && sessions.sessions.length > 0) {
+ // Find oldest (last in list since sorted by created_at desc)
+ const oldest = sessions.sessions[sessions.sessions.length - 1];
+ await this.apiCall(`/api/terminal/sessions/${oldest.session_id}`, { method: 'DELETE' });
+ this.showNotification(`Session fermΓ©e: ${oldest.host_name}`, 'success');
+ }
+
+ // Close modal and retry
+ this.closeSessionLimitModal();
+ await this.openTerminal(targetHostId, targetHostName, targetHostIp);
+ } catch (e) {
+ this.showNotification(`Erreur: ${e.message}`, 'error');
+ }
+ }
+
+ async reuseSessionFromModal(sessionId, targetHostId, targetHostName, targetHostIp) {
+ this.closeSessionLimitModal();
+ // Just retry - the server will return the reusable session
+ await this.openTerminal(targetHostId, targetHostName, targetHostIp);
+ }
+
+ formatDuration(seconds) {
+ if (seconds < 60) return `${seconds}s`;
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
+ return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
+ }
+
openCurrentTerminalPopout() {
if (!this.terminalSession) {
this.showNotification('Aucune session terminal active', 'warning');
@@ -10374,6 +10659,201 @@ class DashboardManager {
// Reopen
await this.openTerminal(host.id, host.name, host.ip);
}
+
+ // ===== TERMINAL COMMAND HISTORY =====
+
+ async toggleTerminalHistory() {
+ const panel = document.getElementById('terminalHistoryPanel');
+ const btn = document.getElementById('terminalHistoryBtn');
+
+ if (!panel) return;
+
+ const isVisible = panel.style.display !== 'none';
+
+ if (isVisible) {
+ panel.style.display = 'none';
+ btn?.classList.remove('active');
+ } else {
+ panel.style.display = 'block';
+ btn?.classList.add('active');
+ // Load history if not loaded
+ if (this.terminalCommandHistory.length === 0) {
+ await this.loadTerminalCommandHistory();
+ }
+ // Focus search input
+ document.getElementById('terminalHistorySearch')?.focus();
+ }
+ }
+
+ async loadTerminalCommandHistory(query = '') {
+ if (!this.terminalSession) return;
+
+ const listEl = document.getElementById('terminalHistoryList');
+ if (!listEl) return;
+
+ this.terminalHistoryLoading = true;
+ listEl.innerHTML = ' Chargement...
';
+
+ try {
+ const allHosts = document.getElementById('terminalHistoryAllHosts')?.checked || false;
+ const hostId = this.terminalSession.host.id;
+
+ let endpoint;
+ if (allHosts) {
+ endpoint = `/api/terminal/command-history?limit=50${query ? `&query=${encodeURIComponent(query)}` : ''}`;
+ } else {
+ endpoint = `/api/terminal/${hostId}/command-history/unique?limit=50${query ? `&query=${encodeURIComponent(query)}` : ''}`;
+ }
+
+ const response = await this.apiCall(endpoint);
+ this.terminalCommandHistory = response.commands || [];
+
+ this.renderTerminalHistory();
+
+ } catch (e) {
+ console.error('Failed to load terminal history:', e);
+ listEl.innerHTML = ' Erreur de chargement
';
+ } finally {
+ this.terminalHistoryLoading = false;
+ }
+ }
+
+ renderTerminalHistory() {
+ const listEl = document.getElementById('terminalHistoryList');
+ if (!listEl) return;
+
+ if (this.terminalCommandHistory.length === 0) {
+ listEl.innerHTML = `
+
+
+ Aucune commande dans l'historique
+
+ `;
+ return;
+ }
+
+ const items = this.terminalCommandHistory.map((cmd, index) => {
+ const command = cmd.command || '';
+ const timeAgo = this.formatRelativeTime(cmd.last_used || cmd.created_at);
+ const execCount = cmd.execution_count || 1;
+ const hostName = cmd.host_name || '';
+
+ return `
+
+
+ ${this.escapeHtml(command.length > 60 ? command.substring(0, 60) + '...' : command)}
+
+
+ ${timeAgo}
+ ${execCount > 1 ? `Γ${execCount}` : ''}
+ ${hostName ? `${this.escapeHtml(hostName)}` : ''}
+
+
+
+
+
+
+ `;
+ }).join('');
+
+ listEl.innerHTML = items;
+ }
+
+ searchTerminalHistory(query) {
+ this.terminalHistorySearchQuery = query;
+
+ // Debounce search
+ if (this._historySearchTimeout) {
+ clearTimeout(this._historySearchTimeout);
+ }
+
+ this._historySearchTimeout = setTimeout(() => {
+ this.loadTerminalCommandHistory(query);
+ }, 300);
+ }
+
+ clearTerminalHistorySearch() {
+ const input = document.getElementById('terminalHistorySearch');
+ if (input) {
+ input.value = '';
+ }
+ this.terminalHistorySearchQuery = '';
+ this.loadTerminalCommandHistory('');
+ }
+
+ toggleHistoryScope() {
+ // Reload with current search query
+ this.loadTerminalCommandHistory(this.terminalHistorySearchQuery);
+ }
+
+ insertTerminalCommand(index) {
+ const cmd = this.terminalCommandHistory[index];
+ if (!cmd) return;
+
+ const command = cmd.command || '';
+
+ // Try to paste into the terminal iframe
+ const iframe = document.getElementById('terminalIframe');
+ if (iframe && iframe.contentWindow) {
+ try {
+ // Write to clipboard and show notification
+ navigator.clipboard.writeText(command).then(() => {
+ this.showNotification('Commande copiΓ©e - Collez avec Ctrl+Shift+V dans le terminal', 'success');
+
+ // Focus the iframe
+ iframe.focus();
+ iframe.contentWindow.focus();
+ }).catch(() => {
+ this.showNotification('Commande: ' + command, 'info');
+ });
+ } catch (e) {
+ this.showNotification('Commande: ' + command, 'info');
+ }
+ } else {
+ navigator.clipboard.writeText(command).then(() => {
+ this.showNotification('Commande copiΓ©e dans le presse-papier', 'success');
+ });
+ }
+
+ // Close history panel
+ this.toggleTerminalHistory();
+ }
+
+ copyTerminalCommand(index) {
+ const cmd = this.terminalCommandHistory[index];
+ if (!cmd) return;
+
+ const command = cmd.command || '';
+
+ navigator.clipboard.writeText(command).then(() => {
+ this.showNotification('Commande copiΓ©e', 'success');
+ }).catch(() => {
+ this.showNotification('Impossible de copier', 'error');
+ });
+ }
+
+ formatRelativeTime(dateStr) {
+ if (!dateStr) return '';
+
+ const date = new Date(dateStr);
+ const now = new Date();
+ const diffMs = now - date;
+ const diffSec = Math.floor(diffMs / 1000);
+ const diffMin = Math.floor(diffSec / 60);
+ const diffHour = Math.floor(diffMin / 60);
+ const diffDay = Math.floor(diffHour / 24);
+
+ if (diffSec < 60) return 'Γ l\'instant';
+ if (diffMin < 60) return `Il y a ${diffMin}min`;
+ if (diffHour < 24) return `Il y a ${diffHour}h`;
+ if (diffDay < 7) return `Il y a ${diffDay}j`;
+
+ return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
+ }
}
// Initialize dashboard when DOM is loaded
diff --git a/app/models/__init__.py b/app/models/__init__.py
index 95f22b4..13fa2d5 100644
--- a/app/models/__init__.py
+++ b/app/models/__init__.py
@@ -14,6 +14,7 @@ from .docker_image import DockerImage
from .docker_volume import DockerVolume
from .docker_alert import DockerAlert
from .terminal_session import TerminalSession
+from .terminal_command_log import TerminalCommandLog
__all__ = [
"Base",
@@ -37,4 +38,5 @@ __all__ = [
"DockerVolume",
"DockerAlert",
"TerminalSession",
+ "TerminalCommandLog",
]
diff --git a/app/models/host.py b/app/models/host.py
index 80a497e..15d123e 100644
--- a/app/models/host.py
+++ b/app/models/host.py
@@ -48,6 +48,9 @@ class Host(Base):
docker_alerts: Mapped[List["DockerAlert"]] = relationship(
"DockerAlert", back_populates="host", cascade="all, delete-orphan"
)
+ terminal_commands: Mapped[List["TerminalCommandLog"]] = relationship(
+ "TerminalCommandLog", back_populates="host", cascade="all, delete-orphan"
+ )
def __repr__(self) -> str: # pragma: no cover - debug helper
return f""
diff --git a/app/models/terminal_command_log.py b/app/models/terminal_command_log.py
new file mode 100644
index 0000000..6e178a1
--- /dev/null
+++ b/app/models/terminal_command_log.py
@@ -0,0 +1,78 @@
+"""
+Model for terminal command logs - stores validated commands executed via terminal.
+
+Security notes:
+- Only stores commands that pass the CommandPolicy validation
+- Commands are masked to remove sensitive values before storage
+- Never stores raw commands that were blocked
+"""
+from __future__ import annotations
+
+from datetime import datetime
+from typing import Optional
+
+from sqlalchemy import Boolean, DateTime, ForeignKey, Index, Integer, String, Text, text
+from sqlalchemy.orm import Mapped, mapped_column, relationship
+from sqlalchemy.sql import func
+
+from .database import Base
+
+
+class TerminalCommandLog(Base):
+ """
+ Stores validated terminal commands for history and audit purposes.
+
+ Commands are only logged if they pass the CommandPolicy validation.
+ Sensitive values are masked before storage.
+ """
+ __tablename__ = "terminal_command_logs"
+
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+
+ # Timestamp of command execution
+ created_at: Mapped[datetime] = mapped_column(
+ DateTime(timezone=True), nullable=False, server_default=func.now(), index=True
+ )
+
+ # Foreign keys
+ host_id: Mapped[str] = mapped_column(
+ String, ForeignKey("hosts.id", ondelete="CASCADE"), nullable=False, index=True
+ )
+ user_id: Mapped[Optional[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)
+
+ # Command data (masked/normalized version only)
+ command: Mapped[str] = mapped_column(Text, nullable=False)
+
+ # SHA-256 hash of normalized command for deduplication
+ command_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
+
+ # Source identifier
+ source: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'terminal'"))
+
+ # 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)
+
+ # Additional metadata
+ username: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
+ host_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
+
+ # Relationships
+ host = relationship("Host", back_populates="terminal_commands", lazy="selectin")
+ user = relationship("User", back_populates="terminal_commands", lazy="selectin")
+
+ # Indexes for efficient querying
+ __table_args__ = (
+ Index('ix_terminal_cmd_host_created', 'host_id', 'created_at'),
+ Index('ix_terminal_cmd_user_created', 'user_id', 'created_at'),
+ Index('ix_terminal_cmd_host_hash', 'host_id', 'command_hash'),
+ )
+
+ def __repr__(self) -> str:
+ cmd_preview = self.command[:30] + "..." if len(self.command) > 30 else self.command
+ return f""
diff --git a/app/models/terminal_session.py b/app/models/terminal_session.py
index c854e2a..0f00392 100644
--- a/app/models/terminal_session.py
+++ b/app/models/terminal_session.py
@@ -6,21 +6,52 @@ from __future__ import annotations
from datetime import datetime
from typing import Optional
-from sqlalchemy import DateTime, Integer, String, text
+from sqlalchemy import DateTime, Index, Integer, String, text
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.sql import func
from .database import Base
+# Session status constants
+SESSION_STATUS_STARTING = "starting"
+SESSION_STATUS_ACTIVE = "active"
+SESSION_STATUS_CLOSING = "closing"
+SESSION_STATUS_CLOSED = "closed"
+SESSION_STATUS_EXPIRED = "expired"
+SESSION_STATUS_ERROR = "error"
+
+# Close reason constants
+CLOSE_REASON_USER = "user_close"
+CLOSE_REASON_TTL = "ttl"
+CLOSE_REASON_IDLE = "idle"
+CLOSE_REASON_QUOTA = "quota"
+CLOSE_REASON_SERVER_SHUTDOWN = "server_shutdown"
+CLOSE_REASON_CLIENT_LOST = "client_lost"
+CLOSE_REASON_REUSED = "reused"
+
+
class TerminalSession(Base):
"""
Represents an active or recent SSH terminal session.
Sessions are created when a user opens a terminal to a host,
and cleaned up after expiration or manual closure.
+
+ Status lifecycle:
+ - starting: Session being created, ttyd spawning
+ - active: Session running, client connected
+ - closing: Session being terminated
+ - closed: Session terminated normally
+ - expired: Session terminated due to TTL/idle timeout
+ - error: Session terminated due to error
"""
__tablename__ = "terminal_sessions"
+
+ __table_args__ = (
+ Index('ix_terminal_sessions_user_status', 'user_id', 'status'),
+ 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)
@@ -39,15 +70,21 @@ class TerminalSession(Base):
# Session mode: 'embedded' or 'popout'
mode: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'embedded'"))
- # Session status: 'active', 'closed', 'expired', 'error'
+ # Session status: 'starting', 'active', 'closing', 'closed', 'expired', 'error'
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)
+
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
+ last_seen_at: Mapped[datetime] = mapped_column(
+ 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)
def __repr__(self) -> str:
- return f""
+ return f""
diff --git a/app/models/user.py b/app/models/user.py
index b53f6b7..fd70cd2 100644
--- a/app/models/user.py
+++ b/app/models/user.py
@@ -6,10 +6,10 @@ from __future__ import annotations
from datetime import datetime
from enum import Enum
-from typing import Optional
+from typing import List, Optional
from sqlalchemy import Boolean, DateTime, String, Text, text
-from sqlalchemy.orm import Mapped, mapped_column
+from sqlalchemy.orm import Mapped, mapped_column, relationship
from sqlalchemy.sql import func
from .database import Base
@@ -74,6 +74,11 @@ class User(Base):
# Soft delete support
deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
+ # Relationships
+ terminal_commands: Mapped[List["TerminalCommandLog"]] = relationship(
+ "TerminalCommandLog", back_populates="user"
+ )
+
def __repr__(self) -> str:
return f""
diff --git a/app/routes/terminal.py b/app/routes/terminal.py
index 4ac1455..edee172 100644
--- a/app/routes/terminal.py
+++ b/app/routes/terminal.py
@@ -19,21 +19,39 @@ from app.core.dependencies import get_db, get_current_user
from app.crud.host import HostRepository
from app.crud.bootstrap_status import BootstrapStatusRepository
from app.crud.terminal_session import TerminalSessionRepository
+from app.crud.terminal_command_log import TerminalCommandLogRepository
from app.schemas.terminal import (
TerminalSessionRequest,
TerminalSessionResponse,
TerminalSessionHost,
TerminalSessionStatus,
TerminalSessionList,
+ CommandHistoryItem,
+ CommandHistoryResponse,
+ UniqueCommandItem,
+ UniqueCommandsResponse,
+ ActiveSessionInfo,
+ SessionLimitError,
+ HeartbeatResponse,
+ SessionMetrics,
)
from app.services.terminal_service import (
terminal_service,
TERMINAL_SESSION_TTL_MINUTES,
+ TERMINAL_SESSION_TTL_SECONDS,
TERMINAL_MAX_SESSIONS_PER_USER,
+ TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS,
+ TERMINAL_HEARTBEAT_INTERVAL_SECONDS,
HostNotReadyError,
SessionLimitExceededError,
TtydNotAvailableError,
)
+from app.models.terminal_session import (
+ SESSION_STATUS_ACTIVE,
+ SESSION_STATUS_CLOSED,
+ CLOSE_REASON_USER,
+ CLOSE_REASON_CLIENT_LOST,
+)
logger = logging.getLogger(__name__)
@@ -62,24 +80,82 @@ async def get_terminal_feature_status():
"ttyd_installed": ttyd_available,
"max_sessions_per_user": TERMINAL_MAX_SESSIONS_PER_USER,
"session_ttl_minutes": TERMINAL_SESSION_TTL_MINUTES,
+ "session_idle_timeout_seconds": TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS,
+ "heartbeat_interval_seconds": TERMINAL_HEARTBEAT_INTERVAL_SECONDS,
"active_sessions": terminal_service.get_active_session_count(),
}
+@router.get("/metrics", response_model=SessionMetrics)
+async def get_terminal_metrics(
+ current_user: dict = Depends(get_current_user),
+):
+ """
+ Get terminal session service metrics.
+
+ Returns counts of sessions created, reused, closed, etc.
+ """
+ metrics = terminal_service.get_metrics()
+ return SessionMetrics(**metrics)
+
+
+def _build_session_limit_error(
+ active_sessions: list,
+ reusable_session=None,
+) -> dict:
+ """
+ Build a rich error response for session limit exceeded.
+ """
+ now = datetime.now(timezone.utc)
+
+ active_info = []
+ for s in active_sessions:
+ created_at = _as_utc_aware(s.created_at) or now
+ last_seen_at = _as_utc_aware(getattr(s, 'last_seen_at', None)) or created_at
+
+ active_info.append(ActiveSessionInfo(
+ session_id=s.id,
+ host_id=s.host_id,
+ host_name=s.host_name,
+ mode=s.mode,
+ age_seconds=int((now - created_at).total_seconds()),
+ last_seen_seconds=int((now - last_seen_at).total_seconds()),
+ ))
+
+ suggested_actions = ["close_oldest"]
+ for s in active_sessions:
+ suggested_actions.append(f"close_session:{s.id}")
+
+ can_reuse = reusable_session is not None
+ if can_reuse:
+ suggested_actions.insert(0, "reuse_existing")
+
+ return SessionLimitError(
+ message=f"Maximum active sessions ({TERMINAL_MAX_SESSIONS_PER_USER}) reached. Close an existing session first.",
+ max_active=TERMINAL_MAX_SESSIONS_PER_USER,
+ current_count=len(active_sessions),
+ active_sessions=active_info,
+ suggested_actions=suggested_actions,
+ can_reuse=can_reuse,
+ reusable_session_id=reusable_session.id if reusable_session else None,
+ ).model_dump()
+
+
@router.post("/{host_id}/terminal-sessions", response_model=TerminalSessionResponse)
async def create_terminal_session(
host_id: str,
+ http_request: Request,
request: TerminalSessionRequest = TerminalSessionRequest(),
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
"""
- Create a new terminal session for a host.
+ Create or reuse a terminal session for a host.
- Requires:
- - Host must exist and be online
- - Host must have bootstrap completed
- - User must not exceed max sessions limit
+ Session reuse policy:
+ - If an active, healthy session exists for same user/host/mode, reuse it
+ - Otherwise create a new session if quota allows
+ - If quota exceeded, return rich error with active sessions list
Returns session URL and metadata.
"""
@@ -126,15 +202,79 @@ async def create_terminal_session(
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
username = current_user.get("username", "api_user")
- # Check session limit
- active_count = await session_repo.count_active_for_user(user_id)
- if active_count >= TERMINAL_MAX_SESSIONS_PER_USER:
- raise HTTPException(
- status_code=status.HTTP_429_TOO_MANY_REQUESTS,
- detail=f"Maximum active sessions ({TERMINAL_MAX_SESSIONS_PER_USER}) reached. Close an existing session first."
+ # Check for reusable session first
+ existing_session = await session_repo.find_reusable_session(
+ user_id=user_id,
+ host_id=host.id,
+ mode=request.mode,
+ idle_timeout_seconds=TERMINAL_SESSION_IDLE_TIMEOUT_SECONDS
+ )
+
+ if existing_session:
+ # Reuse existing session - update last_seen and return
+ await session_repo.update_last_seen(existing_session.id)
+ await db_session.commit()
+
+ terminal_service.record_session_created(reused=True)
+ logger.info(f"session_reused session={existing_session.id[:8]}... user={username} host={host.name}")
+
+ expires_at = _as_utc_aware(existing_session.expires_at)
+ now = datetime.now(timezone.utc)
+ remaining = max(0, int(((expires_at or now) - now).total_seconds()))
+
+ # Note: We don't have the original token, so client must use the existing connection
+ # or we generate a new token and update the session
+ token, token_hash = terminal_service.generate_session_token()
+ existing_session.token_hash = token_hash
+ await db_session.commit()
+
+ connect_path = f"/api/terminal/connect/{existing_session.id}?token={token}"
+ popout_path = f"/api/terminal/popout/{existing_session.id}?token={token}"
+ session_url = popout_path if request.mode == "popout" else connect_path
+
+ ws_scheme = "wss" if (http_request and http_request.url.scheme == "https") else "ws"
+ ws_host = (http_request.url.hostname if http_request else "localhost")
+
+ return TerminalSessionResponse(
+ session_id=existing_session.id,
+ url=session_url,
+ websocket_url=f"{ws_scheme}://{ws_host}:{existing_session.ttyd_port}/ws",
+ expires_at=expires_at,
+ ttl_seconds=remaining,
+ mode=existing_session.mode,
+ host=TerminalSessionHost(
+ id=host.id,
+ name=host.name,
+ ip=host.ip_address,
+ status=host.status or "unknown",
+ bootstrap_ok=bootstrap_ok,
+ ),
+ reused=True,
+ token=token,
)
- # Generate session credentials
+ # No reusable session - check quota
+ active_sessions = await session_repo.list_active_for_user(user_id)
+ active_count = len(active_sessions)
+
+ if active_count >= TERMINAL_MAX_SESSIONS_PER_USER:
+ # Check if any session is for the same host (can be reused with different mode)
+ same_host_session = next(
+ (s for s in active_sessions if s.host_id == host.id),
+ None
+ )
+
+ terminal_service.record_session_limit_hit()
+ logger.warning(f"session_limit_hit user={username} count={active_count} max={TERMINAL_MAX_SESSIONS_PER_USER}")
+
+ # Return rich error with session list
+ from fastapi.responses import JSONResponse
+ return JSONResponse(
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
+ content=_build_session_limit_error(active_sessions, same_host_session),
+ )
+
+ # Create new session
session_id = terminal_service.generate_session_id()
token, token_hash = terminal_service.generate_session_token()
@@ -208,18 +348,20 @@ async def create_terminal_session(
detail="Failed to create terminal session"
)
- # Log audit event
- logger.info(f"Terminal session created: user={username} host={host.name} session={session_id[:8]}...")
+ terminal_service.record_session_created(reused=False)
+ logger.info(f"session_created session={session_id[:8]}... user={username} host={host.name}")
connect_path = f"/api/terminal/connect/{session_id}?token={token}"
popout_path = f"/api/terminal/popout/{session_id}?token={token}"
session_url = popout_path if request.mode == "popout" else connect_path
- # Build response
+ ws_scheme = "wss" if (http_request and http_request.url.scheme == "https") else "ws"
+ ws_host = (http_request.url.hostname if http_request else "localhost")
+
return TerminalSessionResponse(
session_id=session_id,
url=session_url,
- websocket_url=f"ws://localhost:{port}/ws",
+ websocket_url=f"{ws_scheme}://{ws_host}:{port}/ws",
expires_at=expires_at,
ttl_seconds=TERMINAL_SESSION_TTL_MINUTES * 60,
mode=request.mode,
@@ -230,16 +372,22 @@ async def create_terminal_session(
status=host.status or "unknown",
bootstrap_ok=bootstrap_ok,
),
+ reused=False,
+ token=token,
)
@router.get("/sessions", response_model=TerminalSessionList)
async def list_terminal_sessions(
+ status_filter: Optional[str] = None,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
):
"""
List active terminal sessions for the current user.
+
+ Args:
+ status_filter: Optional filter by status (default: active only)
"""
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
@@ -252,14 +400,23 @@ async def list_terminal_sessions(
for session in sessions:
expires_at = _as_utc_aware(session.expires_at)
created_at = _as_utc_aware(session.created_at)
+ last_seen_at = _as_utc_aware(getattr(session, 'last_seen_at', None)) or created_at
remaining = max(0, int(((expires_at or now) - now).total_seconds()))
+ age_seconds = int((now - created_at).total_seconds())
+ last_seen_seconds = int((now - last_seen_at).total_seconds())
+
session_list.append(TerminalSessionStatus(
session_id=session.id,
status=session.status,
+ host_id=session.host_id,
host_name=session.host_name,
+ mode=session.mode,
created_at=created_at,
expires_at=expires_at,
+ last_seen_at=last_seen_at,
remaining_seconds=remaining,
+ age_seconds=age_seconds,
+ last_seen_seconds=last_seen_seconds,
))
return TerminalSessionList(
@@ -279,6 +436,7 @@ async def close_terminal_session(
Close a terminal session.
Terminates the ttyd process and marks session as closed.
+ Idempotent: closing an already closed session returns 204.
"""
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
username = current_user.get("username", "api_user")
@@ -287,10 +445,12 @@ async def close_terminal_session(
session = await session_repo.get(session_id)
if not session:
- raise HTTPException(
- status_code=status.HTTP_404_NOT_FOUND,
- detail="Session not found"
- )
+ # Idempotent: session doesn't exist, consider it closed
+ return {"message": "Session not found or already closed", "session_id": session_id}
+
+ # Already closed - idempotent success
+ if session.status in [SESSION_STATUS_CLOSED, "expired", "error"]:
+ return {"message": "Session already closed", "session_id": session_id}
# Check ownership (unless admin)
if session.user_id != user_id and current_user.get("role") != "admin":
@@ -299,19 +459,106 @@ async def close_terminal_session(
detail="Cannot close another user's session"
)
- # Terminate ttyd process
- await terminal_service.terminate_session(session_id)
- await terminal_service.release_port(session.ttyd_port)
+ # Terminate ttyd process and cleanup
+ await terminal_service.close_session_with_cleanup(
+ session_id=session_id,
+ port=session.ttyd_port,
+ reason=CLOSE_REASON_USER
+ )
# Update database
- await session_repo.close_session(session_id)
+ await session_repo.close_session(session_id, reason=CLOSE_REASON_USER)
await db_session.commit()
- logger.info(f"Terminal session closed: user={username} host={session.host_name} session={session_id[:8]}...")
+ logger.info(f"session_closed session={session_id[:8]}... user={username} host={session.host_name} reason=user_close")
return {"message": "Session closed", "session_id": session_id}
+@router.post("/sessions/{session_id}/heartbeat", response_model=HeartbeatResponse)
+async def heartbeat_terminal_session(
+ session_id: str,
+ current_user: dict = Depends(get_current_user),
+ db_session: AsyncSession = Depends(get_db),
+):
+ """
+ Send heartbeat to keep session alive.
+
+ Updates last_seen_at timestamp. Should be called every 10-20 seconds
+ while terminal is visible/active.
+ """
+ user_id = current_user.get("user_id") or current_user.get("type", "api_key")
+
+ session_repo = TerminalSessionRepository(db_session)
+ session = await session_repo.get_active_by_id(session_id)
+
+ if not session:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="Session not found or expired"
+ )
+
+ # Check ownership
+ if session.user_id != user_id:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN,
+ detail="Cannot heartbeat another user's session"
+ )
+
+ # Update last_seen
+ await session_repo.update_last_seen(session_id)
+ await db_session.commit()
+
+ now = datetime.now(timezone.utc)
+ expires_at = _as_utc_aware(session.expires_at)
+ remaining = max(0, int(((expires_at or now) - now).total_seconds()))
+
+ return HeartbeatResponse(
+ session_id=session_id,
+ status=session.status,
+ last_seen_at=now,
+ remaining_seconds=remaining,
+ healthy=True,
+ )
+
+
+@router.post("/sessions/{session_id}/close-beacon")
+async def close_beacon_terminal_session(
+ session_id: str,
+ request: Request,
+ db_session: AsyncSession = Depends(get_db),
+):
+ """
+ Close session via sendBeacon (for browser close/navigation).
+
+ This endpoint is designed to be called via navigator.sendBeacon()
+ when the browser tab is closing or navigating away. It does not
+ require authentication since the session_id itself is the credential.
+
+ Always returns 204 No Content for sendBeacon compatibility.
+ """
+ session_repo = TerminalSessionRepository(db_session)
+ session = await session_repo.get(session_id)
+
+ if session and session.status == SESSION_STATUS_ACTIVE:
+ # Terminate ttyd process and cleanup
+ await terminal_service.close_session_with_cleanup(
+ session_id=session_id,
+ port=session.ttyd_port,
+ reason=CLOSE_REASON_CLIENT_LOST
+ )
+
+ # Update database
+ await session_repo.close_session(session_id, reason=CLOSE_REASON_CLIENT_LOST)
+ await db_session.commit()
+
+ logger.info(f"session_closed session={session_id[:8]}... host={session.host_name} reason=client_lost")
+
+ # Always return 204 for sendBeacon
+ from fastapi.responses import Response
+ return Response(status_code=204)
+
+
@router.post("/cleanup")
async def cleanup_terminal_sessions(
current_user: dict = Depends(get_current_user),
@@ -363,6 +610,7 @@ async def cleanup_terminal_sessions(
async def get_terminal_connect_page(
session_id: str,
token: str,
+ request: Request,
db_session: AsyncSession = Depends(get_db),
):
"""
@@ -428,11 +676,65 @@ async def get_terminal_connect_page(
""",
status_code=403
)
+
+ # If ttyd process is not alive, return a friendly error page instead of a blank iframe.
+ try:
+ alive = await terminal_service.is_session_process_alive(session_id)
+ except Exception:
+ alive = True
+
+ if not alive:
+ return HTMLResponse(
+ content=f"""
+
+
+
+ {session.host_name}
+
+
+
+
+
Terminal indisponible
+
Le service terminal (ttyd) ne rΓ©pond pas pour cette session.
+
Essayez de reconnecter ou de recrΓ©er une session depuis le dashboard.
+
Retour au Dashboard
+
+
+
+ """,
+ status_code=503,
+ )
# Calculate remaining time
now = datetime.now(timezone.utc)
expires_at = _as_utc_aware(session.expires_at)
remaining_seconds = max(0, int(((expires_at or now) - now).total_seconds()))
+
+ forwarded_proto = request.headers.get("x-forwarded-proto")
+ forwarded_host = request.headers.get("x-forwarded-host")
+ forwarded_port = request.headers.get("x-forwarded-port")
+
+ scheme = forwarded_proto or request.url.scheme
+ host = forwarded_host or request.headers.get("host") or request.url.hostname or "localhost"
+ if ":" in host:
+ host_only = host
+ else:
+ host_only = host
+
+ if forwarded_port and ":" not in host_only:
+ host_only = f"{host_only}:{forwarded_port}"
+
+ ttyd_url = f"{scheme}://{host_only.split(':')[0]}:{session.ttyd_port}/"
# Generate the terminal page
html_content = f"""
@@ -441,7 +743,7 @@ async def get_terminal_connect_page(
- Terminal - {session.host_name}
+ {session.host_name}