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
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:
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user