From d29eefcef4bd03ff8f373bfe3a5232b0c061a32d Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Tue, 3 Mar 2026 11:51:57 -0500 Subject: [PATCH] feat: Implement web-based terminal with session management, command history, and dedicated UI. --- .../0020_add_is_pinned_to_terminal.py | 42 ++ alembic_history.txt | 20 + alembic_out.txt | 97 +++++ app/crud/terminal_command_log.py | 6 +- app/crud/terminal_session.py | 14 + app/index.html | 22 + app/main.js | 198 ++++++--- app/models/terminal_command_log.py | 2 + app/routes/terminal.py | 404 +++++++++++++----- app/schemas/terminal.py | 2 + app/templates/terminal/connect.html | 20 + data/homelab.db | Bin 290816 -> 290816 bytes 12 files changed, 638 insertions(+), 189 deletions(-) create mode 100644 alembic/versions/0020_add_is_pinned_to_terminal.py create mode 100644 alembic_history.txt create mode 100644 alembic_out.txt diff --git a/alembic/versions/0020_add_is_pinned_to_terminal.py b/alembic/versions/0020_add_is_pinned_to_terminal.py new file mode 100644 index 0000000..91ac144 --- /dev/null +++ b/alembic/versions/0020_add_is_pinned_to_terminal.py @@ -0,0 +1,42 @@ +"""add is_pinned to terminal command logs + +Revision ID: 0020 +Revises: 0019_add_container_customizations +Create Date: 2026-03-03 10:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision: str = '0020' +down_revision: Union[str, None] = '0019_add_container_customizations' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Check if column already exists to avoid errors + bind = op.get_bind() + insp = inspect(bind) + columns = [col['name'] for col in insp.get_columns('terminal_command_logs')] + + if 'is_pinned' not in columns: + # Add is_pinned column with default 0 + op.add_column('terminal_command_logs', sa.Column('is_pinned', sa.Boolean(), server_default=sa.text('0'), nullable=False)) + + +def downgrade() -> None: + # Check if column exists before dropping + bind = op.get_bind() + insp = inspect(bind) + columns = [col['name'] for col in insp.get_columns('terminal_command_logs')] + + if 'is_pinned' in columns: + # Drop column + with op.batch_alter_table('terminal_command_logs') as batch_op: + batch_op.drop_column('is_pinned') diff --git a/alembic_history.txt b/alembic_history.txt new file mode 100644 index 0000000..0ab725d --- /dev/null +++ b/alembic_history.txt @@ -0,0 +1,20 @@ +0019_add_container_customizations -> 0020 (head), add is_pinned to terminal command logs +0018 -> 0019_add_container_customizations, Add container_customizations table +0017_add_favorites_tables -> 0018, add icon_key to favorite_groups +0016 -> 0017_add_favorites_tables, Add favorites tables (favorite_groups, favorite_containers) +0015 -> 0016, Add storage_details column to host_metrics table +0014 -> 0015, Add last_seen_at and reason_closed to terminal_sessions +0013 -> 0014, Add terminal_command_logs table for command history +0012 -> 0013, Add terminal_sessions table for SSH web terminal feature. +0011_add_docker_management_tables -> 0012, Add unique constraints to Docker tables. +0010_remove_logs_foreign_keys -> 0011_add_docker_management_tables, Add Docker management tables +0009_add_app_settings_table -> 0010_remove_logs_foreign_keys, Remove foreign key constraints from logs table. +0008_add_lvm_zfs_metrics -> 0009_add_app_settings_table, Add app_settings table +0007_add_alerts_table -> 0008_add_lvm_zfs_metrics, Add LVM/ZFS metrics fields to host_metrics +0006_add_host_metrics_details -> 0007_add_alerts_table, Add alerts table +0005_add_host_metrics -> 0006_add_host_metrics_details, Add detailed CPU/disk fields to host_metrics +0004_add_users -> 0005_add_host_metrics, Add host_metrics table for builtin playbooks data collection +0003_add_notification_type -> 0004_add_users, Add users table for authentication +0002_add_schedule_columns -> 0003_add_notification_type, Add notification_type column to schedules table +0001_initial -> 0002_add_schedule_columns, Add missing columns to schedules table for full scheduler support + -> 0001_initial, Initial database schema for Homelab Automation diff --git a/alembic_out.txt b/alembic_out.txt new file mode 100644 index 0000000..4eb3dca --- /dev/null +++ b/alembic_out.txt @@ -0,0 +1,97 @@ +Traceback (most recent call last): + File "", line 198, in _run_module_as_main + File "", line 88, in _run_code + File "C:\Users\bruno\scoop\apps\python\current\Scripts\alembic.exe\__main__.py", line 5, in + sys.exit(main()) + ~~~~^^ + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\alembic\config.py", line 636, in main + CommandLine(prog=prog).main(argv=argv) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^ + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\alembic\config.py", line 626, in main + self.run_cmd(cfg, options) + ~~~~~~~~~~~~^^^^^^^^^^^^^^ + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\alembic\config.py", line 603, in run_cmd + fn( + ~~^ + config, + ^^^^^^^ + *[getattr(options, k, None) for k in positional], + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + **{k: getattr(options, k, None) for k in kwarg}, + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + ) + ^ + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\alembic\command.py", line 236, in revision + script_directory.run_env() + ~~~~~~~~~~~~~~~~~~~~~~~~^^ + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\alembic\script\base.py", line 586, in run_env + util.load_python_file(self.dir, "env.py") + ~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\alembic\util\pyfiles.py", line 95, in load_python_file + module = load_module_py(module_id, path) + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\alembic\util\pyfiles.py", line 113, in load_module_py + spec.loader.exec_module(module) # type: ignore + ~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^ + File "", line 759, in exec_module + File "", line 491, in _call_with_frames_removed + File "C:\dev\git\python\homelab-automation-api-v2\alembic\env.py", line 20, in + from app.models.database import Base, metadata_obj, DATABASE_URL # noqa: E402 + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\dev\git\python\homelab-automation-api-v2\app\__init__.py", line 14, in + from app.factory import create_app + File "C:\dev\git\python\homelab-automation-api-v2\app\factory.py", line 17, in + from app.models.database import init_db, async_session_maker + File "C:\dev\git\python\homelab-automation-api-v2\app\models\__init__.py", line 2, in + from .host import Host + File "C:\dev\git\python\homelab-automation-api-v2\app\models\host.py", line 13, in + class Host(Base): + ...<42 lines>... + return f"" + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\sqlalchemy\orm\decl_api.py", line 196, in __init__ + _as_declarative(reg, cls, dict_) + ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^ + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\sqlalchemy\orm\decl_base.py", line 244, in _as_declarative + return _MapperConfig.setup_mapping(registry, cls, dict_, None, {}) + ~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\sqlalchemy\orm\decl_base.py", line 325, in setup_mapping + return _ClassScanMapperConfig( + registry, cls_, dict_, table, mapper_kw + ) + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\sqlalchemy\orm\decl_base.py", line 572, in __init__ + self._extract_mappable_attributes() + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^ + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\sqlalchemy\orm\decl_base.py", line 1560, in _extract_mappable_attributes + value.declarative_scan( + ~~~~~~~~~~~~~~~~~~~~~~^ + self, + ^^^^^ + ...<7 lines>... + is_dataclass, + ^^^^^^^^^^^^^ + ) + ^ + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\sqlalchemy\orm\properties.py", line 709, in declarative_scan + self._init_column_for_annotation( + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^ + cls, + ^^^^ + ...<2 lines>... + originating_module, + ^^^^^^^^^^^^^^^^^^^ + ) + ^ + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\sqlalchemy\orm\properties.py", line 751, in _init_column_for_annotation + argument = de_stringify_union_elements( + cls, argument, originating_module + ) + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\sqlalchemy\util\typing.py", line 341, in de_stringify_union_elements + return make_union_type( + *[ + ...<8 lines>... + ] + ) + File "C:\Users\bruno\scoop\apps\python\current\Lib\site-packages\sqlalchemy\util\typing.py", line 478, in make_union_type + return cast(Any, Union).__getitem__(types) # type: ignore + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^ +TypeError: descriptor '__getitem__' requires a 'typing.Union' object but received a 'tuple' +[DB] DATABASE_URL=sqlite+aiosqlite:///C:\dev\git\python\homelab-automation-api-v2\data\homelab.db, DEFAULT_DB_PATH=C:\dev\git\python\homelab-automation-api-v2\data\homelab.db, parent_exists=True, parent=C:\dev\git\python\homelab-automation-api-v2\data diff --git a/app/crud/terminal_command_log.py b/app/crud/terminal_command_log.py index 4663488..15e500f 100644 --- a/app/crud/terminal_command_log.py +++ b/app/crud/terminal_command_log.py @@ -201,6 +201,7 @@ class TerminalCommandLogRepository: TerminalCommandLog.command_hash, func.max(TerminalCommandLog.created_at).label("max_created"), func.count(TerminalCommandLog.id).label("execution_count"), + func.max(TerminalCommandLog.is_pinned).label("is_pinned"), ) .where(and_(*conditions)) .group_by(TerminalCommandLog.command_hash) @@ -214,13 +215,15 @@ class TerminalCommandLogRepository: TerminalCommandLog.command_hash, subq.c.max_created, subq.c.execution_count, + subq.c.is_pinned, ) .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()) + # Sort by pinned status first (pinned at top), then by max_created + .order_by(subq.c.is_pinned.desc(), subq.c.max_created.desc()) .limit(limit) ) @@ -233,6 +236,7 @@ class TerminalCommandLogRepository: "command_hash": row.command_hash, "last_used": row.max_created, "execution_count": row.execution_count, + "is_pinned": bool(row.is_pinned), } for row in rows ] diff --git a/app/crud/terminal_session.py b/app/crud/terminal_session.py index 7f57674..f1166ae 100644 --- a/app/crud/terminal_session.py +++ b/app/crud/terminal_session.py @@ -128,6 +128,20 @@ class TerminalSessionRepository: ).order_by(TerminalSession.created_at.desc()) ) return list(result.scalars().all()) + + async def list_active_for_host(self, host_id: str) -> List[TerminalSession]: + """List all active sessions for a specific host.""" + now = datetime.now(timezone.utc) + result = await self.session.execute( + select(TerminalSession).where( + and_( + TerminalSession.host_id == host_id, + 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).""" diff --git a/app/index.html b/app/index.html index cb64eff..d52a5dc 100644 --- a/app/index.html +++ b/app/index.html @@ -3289,6 +3289,28 @@ accent-color: #7c3aed; cursor: pointer; } + + .terminal-history-filter-btn { + background: rgba(124,58,237,0.08); + border: 1px solid #374151; + border-radius: 0.375rem; + color: #9ca3af; + padding: 0.3rem 0.5rem; + font-size: 0.75rem; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + line-height: 1; + display: flex; + align-items: center; + } + .terminal-history-filter-btn:hover { background: rgba(124,58,237,0.18); border-color: #7c3aed; color: #e5e7eb; } + .terminal-history-filter-btn.active { background: rgba(124,58,237,0.3); border-color: #7c3aed; color: #fbbf24; } + + /* Docked mode: panel stays visible and doesn't close on command execute */ + .terminal-history-panel.docked { + max-height: 280px; + border-bottom: 2px solid #7c3aed; + } .terminal-history-list { flex: 1; diff --git a/app/main.js b/app/main.js index 6915c57..eb410da 100644 --- a/app/main.js +++ b/app/main.js @@ -105,6 +105,8 @@ class DashboardManager { // Terminal History Enhanced State this.terminalHistoryPanelOpen = false; + this.terminalHistoryPanelPinned = false; // true = panel stays open (docked mode) + this.terminalHistoryPinnedOnly = false; // true = show only pinned commands this.terminalHistorySelectedIndex = -1; this.terminalHistoryTimeFilter = 'all'; // all, today, week, month this.terminalHistoryStatusFilter = 'all'; // all, success, error @@ -11423,8 +11425,8 @@ class DashboardManager { const notification = document.createElement('div'); notification.className = `fixed top-20 right-6 z-50 p-4 rounded-lg shadow-lg transition-all duration-300 ${type === 'success' ? 'bg-green-600' : - type === 'warning' ? 'bg-yellow-600' : - type === 'error' ? 'bg-red-600' : 'bg-blue-600' + type === 'warning' ? 'bg-yellow-600' : + type === 'error' ? 'bg-red-600' : 'bg-blue-600' } text-white`; notification.innerHTML = `
@@ -11789,6 +11791,12 @@ class DashboardManager { Tous hôtes + +
@@ -12285,6 +12293,9 @@ class DashboardManager { } closeTerminalHistoryPanel() { + // If panel is pinned/docked, don't close on command execute + if (this.terminalHistoryPanelPinned) return; + const panel = document.getElementById('terminalHistoryPanel'); const btn = document.getElementById('terminalHistoryBtn'); @@ -12320,56 +12331,34 @@ class DashboardManager { const hostId = this.terminalSession.host.id; const timeFilter = this.terminalHistoryTimeFilter; - // Build query params const params = new URLSearchParams(); params.set('limit', '100'); if (query) params.set('query', query); - // Add time filter - if (timeFilter !== 'all') { - const now = new Date(); - let since; - switch (timeFilter) { - case 'today': - since = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - break; - case 'week': - since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - break; - case 'month': - since = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - break; - } - if (since) { - params.set('since', since.toISOString()); - } - } - let endpoint; if (allHosts) { + // Global history - pass session token for auth in the pop-out context + const token = this.terminalSession.token; + if (token) params.set('token', token); endpoint = `/api/terminal/command-history?${params.toString()}`; } else { - // Use shell-history to fetch real commands from the remote host via SSH - endpoint = `/api/terminal/${hostId}/shell-history?${params.toString()}`; + // Per-host unique commands (includes command_hash for pinning) + const token = this.terminalSession.token; + if (token) params.set('token', token); + endpoint = `/api/terminal/${hostId}/command-history/unique?${params.toString()}`; } const response = await this.apiCall(endpoint); let commands = response.commands || []; - // Client-side time filtering if API doesn't support it + // Client-side time filtering if (timeFilter !== 'all') { const now = new Date(); let cutoff; switch (timeFilter) { - case 'today': - cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - break; - case 'week': - cutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); - break; - case 'month': - cutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); - break; + case 'today': cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate()); break; + case 'week': cutoff = new Date(now.getTime() - 7 * 86400000); break; + case 'month': cutoff = new Date(now.getTime() - 30 * 86400000); break; } if (cutoff) { commands = commands.filter(cmd => { @@ -12379,9 +12368,13 @@ class DashboardManager { } } + // Client-side pinned-only filter + if (this.terminalHistoryPinnedOnly) { + commands = commands.filter(cmd => cmd.is_pinned); + } + this.terminalCommandHistory = commands; this.terminalHistorySelectedIndex = -1; - this.renderTerminalHistory(); } catch (e) { @@ -12433,7 +12426,7 @@ class DashboardManager { ondblclick="dashboard.executeHistoryCommand(${index})" title="${this.escapeHtml(command)}">
- ${displayCommand} + ${cmd.is_pinned ? '' : ''}${displayCommand}
${timeAgo} @@ -12441,6 +12434,9 @@ class DashboardManager { ${hostName ? `${this.escapeHtml(hostName)}` : ''}
+ @@ -12557,56 +12553,116 @@ class DashboardManager { this.loadTerminalCommandHistory(this.terminalHistorySearchQuery); } + togglePinnedOnlyFilter() { + this.terminalHistoryPinnedOnly = !this.terminalHistoryPinnedOnly; + const btn = document.getElementById('terminalHistoryPinnedOnly'); + if (btn) btn.classList.toggle('active', this.terminalHistoryPinnedOnly); + this.terminalHistorySelectedIndex = -1; + this.loadTerminalCommandHistory(this.terminalHistorySearchQuery); + } + + toggleHistoryPanelPin() { + this.terminalHistoryPanelPinned = !this.terminalHistoryPanelPinned; + const btn = document.getElementById('terminalHistoryDockBtn'); + if (btn) btn.classList.toggle('active', this.terminalHistoryPanelPinned); + // In pinned mode, reposition panel as docked above terminal body + const panel = document.getElementById('terminalHistoryPanel'); + if (panel) panel.classList.toggle('docked', this.terminalHistoryPanelPinned); + } + + async togglePinHistory(index) { + if (!this.terminalSession) return; + const cmd = this.terminalCommandHistory[index]; + if (!cmd || !cmd.command_hash) return; + const newPinned = !cmd.is_pinned; + + try { + const hostId = this.terminalSession.host.id; + await this.apiCall(`/api/terminal/${hostId}/command-history/${cmd.command_hash}/pin`, { + method: 'POST', + body: JSON.stringify({ is_pinned: newPinned }) + }); + + cmd.is_pinned = newPinned; + this.renderTerminalHistory(this.terminalHistorySearchQuery); + } catch (e) { + this.showNotification("Impossible de modifier l'épingle", "error"); + } + } + + // Find the xterm instance inside the terminal iframe (or nested iframe) + _getTermFromIframe() { + // The side-terminal embeds the connect page in #terminalIframe + // Inside that page, the ttyd terminal runs in #terminalFrame + // We try both levels. + const iframe = document.getElementById('terminalIframe'); + if (!iframe || !iframe.contentWindow) return null; + try { + // Level 1: the connect page itself (pop-out case) + const cw1 = iframe.contentWindow; + if (cw1.term && typeof cw1.term.paste === 'function') return { term: cw1.term, win: cw1 }; + // Level 2: nested #terminalFrame inside connect page (side-terminal case) + const inner = cw1.document && cw1.document.getElementById('terminalFrame'); + if (inner && inner.contentWindow) { + const cw2 = inner.contentWindow; + if (cw2.term && typeof cw2.term.paste === 'function') return { term: cw2.term, win: cw2 }; + } + } catch (e) { /* cross-origin */ } + return null; + } + selectAndInsertHistoryCommand(index) { const cmd = this.terminalCommandHistory[index]; if (!cmd) return; - const command = cmd.command || ''; - // Copy to clipboard and show notification + const found = this._getTermFromIframe(); + if (found) { + found.term.focus(); + found.term.paste(command); + // Don't close panel (insert mode) + return; + } + + // Fallback: postMessage to connect page + const iframe = document.getElementById('terminalIframe'); + if (iframe && iframe.contentWindow) { + iframe.contentWindow.postMessage({ type: 'terminal:paste', text: command }, '*'); + return; + } + + // Last resort: clipboard this.copyTextToClipboard(command).then(() => { this.showNotification('Commande copiée - Collez avec Ctrl+Shift+V', 'success'); - - // Best-effort log - this.logTerminalCommand(command); - - // Close history panel and focus terminal - this.closeTerminalHistoryPanel(); - - // Focus the iframe - const iframe = document.getElementById('terminalIframe'); - if (iframe && iframe.contentWindow) { - iframe.focus(); - iframe.contentWindow.focus(); - } - }).catch(() => { - this.showNotification('Commande: ' + command, 'info'); - }); + }).catch(() => { }); } executeHistoryCommand(index) { const cmd = this.terminalCommandHistory[index]; if (!cmd) return; - const command = cmd.command || ''; - // Copy command + newline to execute it - this.copyTextToClipboard(command + '\n').then(() => { - this.showNotification('Commande copiée avec Enter - Collez pour exécuter', 'success'); - - // Best-effort log - this.logTerminalCommand(command); - + const found = this._getTermFromIframe(); + if (found) { + found.term.focus(); + found.term.paste(command + '\r'); this.closeTerminalHistoryPanel(); + return; + } - const iframe = document.getElementById('terminalIframe'); - if (iframe && iframe.contentWindow) { - iframe.focus(); - iframe.contentWindow.focus(); - } - }).catch(() => { - this.showNotification('Commande: ' + command, 'info'); - }); + // Fallback: postMessage to connect page + const iframe = document.getElementById('terminalIframe'); + if (iframe && iframe.contentWindow) { + iframe.contentWindow.postMessage({ type: 'terminal:paste', text: command + '\r' }, '*'); + this.closeTerminalHistoryPanel(); + return; + } + + // Last resort: clipboard + newline + this.copyTextToClipboard(command + '\n').then(() => { + this.showNotification('Commande copiée - Collez pour exécuter', 'success'); + this.closeTerminalHistoryPanel(); + }).catch(() => { }); } copyTerminalCommand(index) { diff --git a/app/models/terminal_command_log.py b/app/models/terminal_command_log.py index 6e178a1..3f61533 100644 --- a/app/models/terminal_command_log.py +++ b/app/models/terminal_command_log.py @@ -54,6 +54,8 @@ class TerminalCommandLog(Base): # Source identifier source: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'terminal'")) + is_pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0")) + # 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) diff --git a/app/routes/terminal.py b/app/routes/terminal.py index 23adfdf..cf7da97 100644 --- a/app/routes/terminal.py +++ b/app/routes/terminal.py @@ -18,6 +18,8 @@ from urllib.parse import urlencode, urlparse, parse_qs from datetime import datetime, timedelta, timezone from typing import Optional +from pydantic import BaseModel + from app.services.shell_history_service import shell_history_service, ShellHistoryError from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status @@ -26,7 +28,7 @@ from fastapi.templating import Jinja2Templates from sqlalchemy.ext.asyncio import AsyncSession from app.core.config import settings -from app.core.dependencies import get_db, get_current_user, require_debug_mode +from app.core.dependencies import get_db, get_current_user, get_current_user_optional, require_debug_mode _templates = Jinja2Templates(directory=str(settings.base_dir / "templates")) from app.crud.host import HostRepository @@ -181,15 +183,46 @@ def _get_session_token_from_request(request: Request, session_id: str, token: Op referer = request.headers.get("referer") if referer: try: - referer_qs = parse_qs(urlparse(referer).query) - referer_token = referer_qs.get("token", [None])[0] - if referer_token: - return referer_token + parsed = urlparse(referer) + qs = parse_qs(parsed.query) + if "token" in qs: + return qs["token"][0] except Exception: pass return None +async def _verify_history_access( + host_id: str, + request: Request, + db_session: AsyncSession, + current_user: Optional[dict] = None +) -> bool: + """Verify access to history for a specific host (JWT or Session Token).""" + if current_user: + return True + + token = _get_session_token_from_request(request, host_id, token=request.query_params.get("token")) + if not token: + return False + + session_repo = TerminalSessionRepository(db_session) + # Try by ID first + sessions = await session_repo.list_active_for_host(host_id) + + # Try by name if no sessions found (host_id might be a name) + if not sessions: + host_repo = HostRepository(db_session) + host = await host_repo.get_by_name(host_id) + if host: + sessions = await session_repo.list_active_for_host(host.id) + + for s in sessions: + if terminal_service.verify_token(token, s.token_hash): + return True + + return False + def _get_session_token_from_websocket(websocket: WebSocket, session_id: str) -> Optional[str]: token = websocket.query_params.get("token") if token: @@ -884,54 +917,76 @@ async def cleanup_terminal_sessions( "expired_marked": 0, } +class LogCommandRequest(BaseModel): + command: str + @router.post("/sessions/{session_id}/command") async def log_terminal_command( session_id: str, - command_data: dict, + payload: LogCommandRequest, request: Request, - current_user: dict = Depends(get_current_user), db_session: AsyncSession = Depends(get_db), ): - session_repo = TerminalSessionRepository(db_session) - session = await session_repo.get(session_id) + try: + 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" + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Verify session token + actual_token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token")) + if not actual_token or not terminal_service.verify_token(actual_token, session.token_hash): + raise HTTPException(status_code=403, detail="Invalid session token") + + cmd = payload.command.strip() + if not cmd: + return {"status": "ignored", "reason": "empty_command"} + + if len(cmd) > _MAX_TERMINAL_COMMAND_LENGTH: + cmd = cmd[:_MAX_TERMINAL_COMMAND_LENGTH] + + try: + from app.security.command_policy import get_command_policy + policy = get_command_policy() + result = policy.evaluate(cmd) + + # If not allowed and not blocked, we ignore it (UNKNOWN) + if not result.should_log and not result.is_blocked: + return {"status": "ignored", "reason": "not_allowed_to_log"} + + is_blocked = result.is_blocked + reason = result.reason + masked_cmd = result.masked_command or ("[BLOCKED]" if is_blocked else cmd) + cmd_hash = result.command_hash or hashlib.sha256(masked_cmd.encode('utf-8')).hexdigest() + except Exception as pe: + logger.warning(f"Policy evaluation failure: {pe}") + is_blocked = False + reason = str(pe) + masked_cmd = cmd + cmd_hash = hashlib.sha256(cmd.encode('utf-8')).hexdigest() + + cmd_repo = TerminalCommandLogRepository(db_session) + await cmd_repo.create( + host_id=session.host_id, + host_name=session.host_name, + user_id=session.user_id, + username=session.username, + terminal_session_id=session.id, + command=masked_cmd, + command_hash=cmd_hash, + source="terminal_dynamic", + is_blocked=is_blocked, + blocked_reason=reason, ) - token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token")) - if not token or not terminal_service.verify_token(token, session.token_hash): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid session token" - ) - - command = command_data.get("command") - if not command: - return {"status": "ignored", "reason": "empty_command"} - - if len(command) > _MAX_TERMINAL_COMMAND_LENGTH: - command = command[:_MAX_TERMINAL_COMMAND_LENGTH] - - cmd_repo = TerminalCommandLogRepository(db_session) - command_hash = hashlib.sha256(command.encode()).hexdigest() - - await cmd_repo.create( - host_id=session.host_id, - host_name=session.host_name, - user_id=session.user_id, - username=session.username, - terminal_session_id=session.id, - command=command, - command_hash=command_hash, - source="terminal_interactive" - ) - - await db_session.commit() - - return {"status": "logged", "command_len": len(command)} + await db_session.commit() + return {"status": "success", "blocked": is_blocked} + except HTTPException: + raise + except Exception as e: + logger.error(f"Error logging terminal command: {e}") + raise HTTPException(status_code=500, detail=str(e)) @router.get("/connect/{session_id}") async def get_terminal_connect_page( @@ -1049,20 +1104,24 @@ async def get_terminal_connect_page( ) history_js = """ - let historyPanelOpen = false, historySelectedIndex = -1, historySearchQuery = '', historyTimeFilter = 'all'; - async function toggleHistory() { + // ---- History panel state ---- + let historyPanelOpen = false, historyPanelPinned = false; + let historySelectedIndex = -1, historySearchQuery = '', historyTimeFilter = 'all'; + let historyPinnedOnly = false; + + // ---- Panel open/close ---- + function toggleHistory() { + if (historyPanelOpen && !historyPanelPinned) { closeHistoryPanel(); return; } const panel = document.getElementById('terminalHistoryPanel'); const btn = document.getElementById('btnHistory'); - if (historyPanelOpen) { closeHistoryPanel(); } - else { - panel.style.display = 'flex'; panel.classList.add('open'); btn.classList.add('active'); - historyPanelOpen = true; historySelectedIndex = -1; - const si = document.getElementById('terminalHistorySearch'); - if (si) { si.focus(); si.select(); } - if (historyData.length === 0) loadHistory(); - } + panel.style.display = 'flex'; panel.classList.add('open'); btn.classList.add('active'); + historyPanelOpen = true; historySelectedIndex = -1; + const si = document.getElementById('terminalHistorySearch'); + if (si) { si.focus(); si.select(); } + if (historyData.length === 0) loadHistory(); } function closeHistoryPanel() { + if (historyPanelPinned) return; const panel = document.getElementById('terminalHistoryPanel'); const btn = document.getElementById('btnHistory'); panel.classList.remove('open'); btn.classList.remove('active'); @@ -1070,15 +1129,33 @@ async def get_terminal_connect_page( setTimeout(() => { try { panel.style.display = 'none'; } catch(e){} }, 200); document.getElementById('terminalFrame').focus(); } + function toggleHistoryPin() { + historyPanelPinned = !historyPanelPinned; + const btn = document.getElementById('btnHistoryPin'); + const panel = document.getElementById('terminalHistoryPanel'); + btn?.classList.toggle('active', historyPanelPinned); + panel?.classList.toggle('docked', historyPanelPinned); + } + function togglePinnedOnly() { + historyPinnedOnly = !historyPinnedOnly; + const btn = document.getElementById('btnPinnedOnly'); + btn?.classList.toggle('active', historyPinnedOnly); + historySelectedIndex = -1; loadHistory(); + } + + // ---- Data loading ---- async function loadHistory() { const list = document.getElementById('terminalHistoryList'); list.innerHTML = '
Chargement...
'; const allHosts = document.getElementById('terminalHistoryAllHosts')?.checked || false; const query = historySearchQuery; - let ep = allHosts ? '/api/terminal/command-history?limit=100' : `/api/terminal/${HOST_ID}/shell-history?limit=100`; - if (query) ep += (ep.includes('?') ? '&' : '?') + 'query=' + encodeURIComponent(query); + let ep = allHosts + ? `/api/terminal/command-history?limit=100&token=${encodeURIComponent(TOKEN)}` + : `/api/terminal/${HOST_ID}/command-history/unique?limit=100&token=${encodeURIComponent(TOKEN)}`; + if (query) ep += '&query=' + encodeURIComponent(query); try { const res = await fetch(ep, { headers: { 'Authorization': 'Bearer ' + TOKEN } }); + if (!res.ok) { list.innerHTML = `
Erreur ${res.status}
`; return; } const data = await res.json(); let cmds = data.commands || []; if (historyTimeFilter !== 'all') { @@ -1089,38 +1166,82 @@ async def get_terminal_connect_page( else if (historyTimeFilter === 'month') cutoff = new Date(now.getTime() - 30*86400000); if (cutoff) cmds = cmds.filter(c => new Date(c.last_used||c.created_at) >= cutoff); } + if (historyPinnedOnly) cmds = cmds.filter(c => c.is_pinned); historyData = cmds; historySelectedIndex = -1; renderHistory(); - } catch(e) { list.innerHTML = '
Erreur
'; } + } catch(e) { list.innerHTML = '
Erreur réseau
'; } } + async function togglePinH(i) { + const cmd = historyData[i]; + if (!cmd || !cmd.command_hash) return; + const newPinned = !cmd.is_pinned; + try { + const res = await fetch(`/api/terminal/${HOST_ID}/command-history/${cmd.command_hash}/pin?token=${encodeURIComponent(TOKEN)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN }, + body: JSON.stringify({ is_pinned: newPinned }) + }); + if (res.ok) { cmd.is_pinned = newPinned; renderHistory(); } + } catch(e) {} + } + + // ---- Helpers ---- function escH(t){return (t||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');} - function escRE(s){return s.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');} + function escRE(s){return s.replace(/[.*+?^${}()|[\\\\]\\\\\\\\]/g, '\\\\\\\\$&');} function relTime(d){if(!d)return'';const dt=new Date(d),now=new Date(),ds=Math.floor((now-dt)/1000);if(ds<60)return'À l instant';const dm=Math.floor(ds/60),dh=Math.floor(dm/60),dd=Math.floor(dh/24);if(dm<60)return`Il y a ${dm}min`;if(dh<24)return`Il y a ${dh}h`;if(dd<7)return`Il y a ${dd}j`;return dt.toLocaleDateString('fr-FR',{day:'numeric',month:'short'});} + + // ---- Execution ---- + function execH(i){ + const cmd = historyData[i]; + if(cmd && cmd.command) { + const fr = document.getElementById('terminalFrame'); + const cw = fr ? fr.contentWindow : null; + if(cw && cw.term) { + cw.term.focus(); + cw.term.paste(cmd.command + '\\r'); + closeHistoryPanel(); + } else if (cw) { + cw.postMessage({ type: 'terminal:paste', text: cmd.command + '\\r' }, '*'); + closeHistoryPanel(); + } else { + copyH(i); + } + } + } + function copyH(i){const c=historyData[i];if(c)copyTextToClipboard(c.command).catch(()=>{});} + + // ---- Rendering ---- function renderHistory(){ const list=document.getElementById('terminalHistoryList'),q=historySearchQuery||''; - if(!historyData.length){list.innerHTML=`
${q?`Aucun résultat pour "${escH(q)}"`:'Aucune commande'}
`;return;} + if(!historyData.length){list.innerHTML=`
${q?`Aucun résultat pour "${escH(q)}"`:historyPinnedOnly?'Aucune commande épinglée':'Aucune commande'}
`;return;} list.innerHTML=historyData.map((cmd,i)=>{ - const c=cmd.command||'',ta=relTime(cmd.last_used||cmd.created_at),ec=cmd.execution_count||1,sel=i===historySelectedIndex; - let dc=escH(c.length>60?c.substring(0,60)+'...':c); + const c=cmd.command||'',ta=relTime(cmd.last_used||cmd.created_at),ec=cmd.execution_count||1,sel=i===historySelectedIndex,pinned=cmd.is_pinned; + let dc=escH(c.length>80?c.substring(0,80)+'...':c); if(q)dc=dc.replace(new RegExp(`(${escRE(q)})`,'gi'),'$1'); - return `
${dc}
${ta}${ec>1?`×${ec}`:''}
`; + return `
${pinned?'':''}${dc}
${ta}${ec>1?`×${ec}`:''}
`; }).join(''); if(historySelectedIndex>=0){const s=list.querySelector('.selected');if(s)s.scrollIntoView({block:'nearest',behavior:'smooth'});} } - function handleHistoryKeydown(e){const k=e.key,l=historyData.length;if(k==='ArrowDown'){e.preventDefault();if(l>0){historySelectedIndex=Math.min(historySelectedIndex+1,l-1);renderHistory();}}else if(k==='ArrowUp'){e.preventDefault();if(l>0){historySelectedIndex=Math.max(historySelectedIndex-1,0);renderHistory();}}else if(k==='Enter'){e.preventDefault();if(historySelectedIndex>=0)selectH(historySelectedIndex);else if(l>0)selectH(0);}else if(k==='Escape'){e.preventDefault();closeHistoryPanel();}} + function handleHistoryKeydown(e){const k=e.key,l=historyData.length;if(k==='ArrowDown'){e.preventDefault();if(l>0){historySelectedIndex=Math.min(historySelectedIndex+1,l-1);renderHistory();}}else if(k==='ArrowUp'){e.preventDefault();if(l>0){historySelectedIndex=Math.max(historySelectedIndex-1,0);renderHistory();}}else if(k==='Enter'){e.preventDefault();if(historySelectedIndex>=0)execH(historySelectedIndex);else if(l>0)execH(0);}else if(k==='Escape'){e.preventDefault();closeHistoryPanel();}} let _st=null; function searchHistory(q){historySearchQuery=q;historySelectedIndex=-1;if(_st)clearTimeout(_st);_st=setTimeout(()=>loadHistory(),250);} function clearHistorySearch(){const i=document.getElementById('terminalHistorySearch');if(i){i.value='';i.focus();}historySearchQuery='';historySelectedIndex=-1;loadHistory();} function setHistoryTimeFilter(v){historyTimeFilter=v;historySelectedIndex=-1;loadHistory();} function toggleHistoryScope(){historySelectedIndex=-1;loadHistory();} - function selectH(i){historySelectedIndex=i;renderHistory();} - function execH(i){selectH(i);closeHistoryPanel();} - function copyH(i){const c=historyData[i];if(c)copyTextToClipboard(c.command).catch(()=>{});} document.addEventListener('keydown',(e)=>{ - if(e.key==='Escape'&&historyPanelOpen)closeHistoryPanel(); + if(e.key==='Escape'&&historyPanelOpen&&!historyPanelPinned)closeHistoryPanel(); if((e.ctrlKey||e.metaKey)&&e.key==='r'){e.preventDefault();toggleHistory();} }); + // postMessage handler so parent can paste into this terminal + window.addEventListener('message', (ev) => { + if (ev.data && ev.data.type === 'terminal:paste') { + const fr = document.getElementById('terminalFrame'); + const cw = fr ? fr.contentWindow : null; + if (cw && cw.term) { cw.term.focus(); cw.term.paste(ev.data.text); } + } + }); """ + script_block = ( "