Add terminal session management with heartbeat monitoring, idle timeout detection, session reuse logic, and command history panel UI with search and filtering capabilities
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:
2025-12-18 13:49:40 -05:00
parent 493668f746
commit 5bc12d0729
74 changed files with 14849 additions and 78 deletions
+4
View File
@@ -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",
]
+312
View File
@@ -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
+167 -21
View File
@@ -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()