feat: Implement web-based terminal with session management, command history, and dedicated UI.
Some checks failed
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:
Bruno Charest 2026-03-03 11:51:57 -05:00
parent 88742892d0
commit d29eefcef4
12 changed files with 638 additions and 189 deletions

View File

@ -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')

20
alembic_history.txt Normal file
View File

@ -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
<base> -> 0001_initial, Initial database schema for Homelab Automation

97
alembic_out.txt Normal file
View File

@ -0,0 +1,97 @@
Traceback (most recent call last):
File "<frozen runpy>", line 198, in _run_module_as_main
File "<frozen runpy>", line 88, in _run_code
File "C:\Users\bruno\scoop\apps\python\current\Scripts\alembic.exe\__main__.py", line 5, in <module>
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 "<frozen importlib._bootstrap_external>", line 759, in exec_module
File "<frozen importlib._bootstrap>", line 491, in _call_with_frames_removed
File "C:\dev\git\python\homelab-automation-api-v2\alembic\env.py", line 20, in <module>
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 <module>
from app.factory import create_app
File "C:\dev\git\python\homelab-automation-api-v2\app\factory.py", line 17, in <module>
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 <module>
from .host import Host
File "C:\dev\git\python\homelab-automation-api-v2\app\models\host.py", line 13, in <module>
class Host(Base):
...<42 lines>...
return f"<Host id={self.id} name={self.name} ip={self.ip_address}>"
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

View File

@ -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
]

View File

@ -129,6 +129,20 @@ class TerminalSessionRepository:
)
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)."""
now = datetime.now(timezone.utc)

View File

@ -3290,6 +3290,28 @@
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;
overflow-y: auto;

View File

@ -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
@ -11789,6 +11791,12 @@ class DashboardManager {
<input type="checkbox" id="terminalHistoryAllHosts" onchange="dashboard.toggleHistoryScope()">
<span>Tous hôtes</span>
</label>
<button class="terminal-history-filter-btn" id="terminalHistoryPinnedOnly" onclick="dashboard.togglePinnedOnlyFilter()" title="Afficher uniquement les épinglées">
<i class="fas fa-thumbtack"></i>
</button>
<button class="terminal-history-filter-btn" id="terminalHistoryDockBtn" onclick="dashboard.toggleHistoryPanelPin()" title="Garder le panneau ouvert">
<i class="fas fa-map-pin"></i>
</button>
</div>
</div>
<div class="terminal-history-list" id="terminalHistoryList">
@ -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)}">
<div class="terminal-history-cmd">
<code>${displayCommand}</code>
<code>${cmd.is_pinned ? '<i class="fas fa-thumbtack" style="color:#fbbf24;margin-right:4px;"></i>' : ''}${displayCommand}</code>
</div>
<div class="terminal-history-meta">
<span class="terminal-history-time" title="${new Date(cmd.last_used || cmd.created_at).toLocaleString('fr-FR')}">${timeAgo}</span>
@ -12441,6 +12434,9 @@ class DashboardManager {
${hostName ? `<span class="terminal-history-host">${this.escapeHtml(hostName)}</span>` : ''}
</div>
<div class="terminal-history-actions-inline">
<button class="terminal-history-action" onclick="event.stopPropagation(); dashboard.togglePinHistory(${index})" title="${cmd.is_pinned ? 'Désépingler' : 'Épingler'}">
<i class="fas fa-thumbtack" style="${cmd.is_pinned ? 'color:#fbbf24' : ''}"></i>
</button>
<button class="terminal-history-action" onclick="event.stopPropagation(); dashboard.copyTerminalCommand(${index})" title="Copier">
<i class="fas fa-copy"></i>
</button>
@ -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
this.copyTextToClipboard(command).then(() => {
this.showNotification('Commande copiée - Collez avec Ctrl+Shift+V', 'success');
const found = this._getTermFromIframe();
if (found) {
found.term.focus();
found.term.paste(command);
// Don't close panel (insert mode)
return;
}
// Best-effort log
this.logTerminalCommand(command);
// Close history panel and focus terminal
this.closeTerminalHistoryPanel();
// Focus the iframe
// Fallback: postMessage to connect page
const iframe = document.getElementById('terminalIframe');
if (iframe && iframe.contentWindow) {
iframe.focus();
iframe.contentWindow.focus();
iframe.contentWindow.postMessage({ type: 'terminal:paste', text: command }, '*');
return;
}
}).catch(() => {
this.showNotification('Commande: ' + command, 'info');
});
// Last resort: clipboard
this.copyTextToClipboard(command).then(() => {
this.showNotification('Commande copiée - Collez avec Ctrl+Shift+V', 'success');
}).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;
}
// Fallback: postMessage to connect page
const iframe = document.getElementById('terminalIframe');
if (iframe && iframe.contentWindow) {
iframe.focus();
iframe.contentWindow.focus();
iframe.contentWindow.postMessage({ type: 'terminal:paste', text: command + '\r' }, '*');
this.closeTerminalHistoryPanel();
return;
}
}).catch(() => {
this.showNotification('Commande: ' + command, 'info');
});
// Last resort: clipboard + newline
this.copyTextToClipboard(command + '\n').then(() => {
this.showNotification('Commande copiée - Collez pour exécuter', 'success');
this.closeTerminalHistoryPanel();
}).catch(() => { });
}
copyTerminalCommand(index) {

View File

@ -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)

View File

@ -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),
):
try:
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)
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"
)
raise HTTPException(status_code=404, detail="Session not found")
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"
)
# 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")
command = command_data.get("command")
if not command:
cmd = payload.command.strip()
if not cmd:
return {"status": "ignored", "reason": "empty_command"}
if len(command) > _MAX_TERMINAL_COMMAND_LENGTH:
command = command[:_MAX_TERMINAL_COMMAND_LENGTH]
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)
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"
command=masked_cmd,
command_hash=cmd_hash,
source="terminal_dynamic",
is_blocked=is_blocked,
blocked_reason=reason,
)
await db_session.commit()
return {"status": "logged", "command_len": len(command)}
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();
}
}
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 = '<div class="terminal-history-loading"><i class="fas fa-spinner fa-spin"></i> Chargement...</div>';
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 = `<div class="terminal-history-empty"><i class="fas fa-exclamation-circle"></i> Erreur ${res.status}</div>`; 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 = '<div class="terminal-history-empty"><i class="fas fa-exclamation-circle"></i> Erreur</div>'; }
} catch(e) { list.innerHTML = '<div class="terminal-history-empty"><i class="fas fa-exclamation-circle"></i> Erreur réseau</div>'; }
}
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#039;');}
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=`<div class="terminal-history-empty"><i class="fas fa-terminal"></i><span>${q?`Aucun résultat pour "${escH(q)}"`:'Aucune commande'}</span></div>`;return;}
if(!historyData.length){list.innerHTML=`<div class="terminal-history-empty"><i class="fas fa-terminal"></i><span>${q?`Aucun résultat pour "${escH(q)}"`:historyPinnedOnly?'Aucune commande épinglée':'Aucune commande'}</span></div>`;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'),'<mark>$1</mark>');
return `<div class="terminal-history-item${sel?' selected':''}" data-index="${i}" onclick="selectH(${i})" ondblclick="execH(${i})" title="${escH(c)}"><div class="terminal-history-cmd"><code>${dc}</code></div><div class="terminal-history-meta"><span class="terminal-history-time">${ta}</span>${ec>1?`<span class="terminal-history-count">×${ec}</span>`:''}</div><div class="terminal-history-actions-inline"><button class="terminal-history-action" onclick="event.stopPropagation();copyH(${i})" title="Copier"><i class="fas fa-copy"></i></button><button class="terminal-history-action terminal-history-action-execute" onclick="event.stopPropagation();execH(${i})" title="Exécuter"><i class="fas fa-play"></i></button></div></div>`;
return `<div class="terminal-history-item${sel?' selected':''}" data-index="${i}" onclick="execH(${i})" title="${escH(c)}"><div class="terminal-history-cmd"><code>${pinned?'<i class="fas fa-thumbtack" style="color:#fbbf24;margin-right:4px;"></i>':''}${dc}</code></div><div class="terminal-history-meta"><span class="terminal-history-time">${ta}</span>${ec>1?`<span class="terminal-history-count">×${ec}</span>`:''}</div><div class="terminal-history-actions-inline"><button class="terminal-history-action" onclick="event.stopPropagation();togglePinH(${i})" title="${pinned?'Désépingler':'Épingler'}"><i class="fas fa-thumbtack" style="${pinned?'color:#fbbf24':''}"></i></button><button class="terminal-history-action" onclick="event.stopPropagation();copyH(${i})" title="Copier"><i class="fas fa-copy"></i></button><button class="terminal-history-action terminal-history-action-execute" onclick="event.stopPropagation();execH(${i})" title="Exécuter"><i class="fas fa-play"></i></button></div></div>`;
}).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 = (
"<script>\n"
f" const SESSION_ID = {js_session_id};\n"
@ -1179,7 +1300,32 @@ async def get_terminal_connect_page(
" const frame = document.getElementById('terminalFrame');\n"
" const loading = document.getElementById('terminalLoading');\n"
" if (!frame) return;\n"
" frame.addEventListener('load', () => { iframeLoaded = true; if (loading) loading.classList.add('hidden'); });\n"
" frame.addEventListener('load', () => { \n"
" iframeLoaded = true; if (loading) loading.classList.add('hidden');\n"
" setTimeout(() => { \n"
" try { \n"
" const cw = frame.contentWindow; \n"
" if (cw && cw.term) { \n"
" let cmdBuf = ''; \n"
" cw.term.onData(data => { \n"
" if (data === '\\r') { \n"
" const c = cmdBuf.trim(); \n"
" if (c) { \n"
" fetch('/api/terminal/sessions/' + SESSION_ID + '/command', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN }, body: JSON.stringify({ command: c }) }).catch(()=>{}); \n"
" } \n"
" cmdBuf = ''; \n"
" } else if (data === '\\x7f') { \n"
" cmdBuf = cmdBuf.slice(0, -1); \n"
" } else if (data >= ' ' && data <= '~') { \n"
" cmdBuf += data; \n"
" } else if (data === '\\u0003') { \n"
" cmdBuf = ''; \n"
" } \n"
" }); \n"
" } \n"
" } catch(e) {} \n"
" }, 1500); \n"
" });\n"
" const qs = new URLSearchParams();\n"
" qs.set('token', TOKEN);\n"
" ttydUrl = '/api/terminal/proxy/' + encodeURIComponent(SESSION_ID) + '/?' + qs.toString();\n"
@ -1238,25 +1384,16 @@ async def get_terminal_popout_page(
@router.get("/{host_id}/command-history", response_model=CommandHistoryResponse)
async def get_host_command_history(
host_id: str,
request: Request,
query: Optional[str] = None,
limit: int = 50,
offset: int = 0,
current_user: dict = Depends(get_current_user),
current_user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get command history for a specific host.
Args:
host_id: Host ID to get history for
query: Optional search query to filter commands
limit: Maximum number of results (default 50)
offset: Number of results to skip for pagination
Returns:
List of commands with timestamps and metadata
"""
"""Get command history for a specific host."""
if not await _verify_history_access(host_id, request, db_session, current_user):
raise HTTPException(status_code=401, detail="Authentication required")
# Verify host exists
host_repo = HostRepository(db_session)
host = await host_repo.get(host_id)
@ -1301,26 +1438,17 @@ async def get_host_command_history(
@router.get("/{host_id}/shell-history")
async def get_host_shell_history(
host_id: str,
request: Request,
query: Optional[str] = None,
limit: int = 100,
current_user: dict = Depends(get_current_user),
current_user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get shell history directly from the remote host via SSH.
This fetches the actual ~/.bash_history file from the host,
providing real-time command history.
Args:
host_id: Host ID to get history for
query: Optional search query to filter commands
limit: Maximum number of results (default 100)
Returns:
List of commands from the remote shell history
"""
if not await _verify_history_access(host_id, request, db_session, current_user):
raise HTTPException(status_code=401, detail="Authentication required")
# Verify host exists
host_repo = HostRepository(db_session)
host = await host_repo.get(host_id)
@ -1376,18 +1504,15 @@ async def get_host_shell_history(
@router.get("/{host_id}/command-history/unique", response_model=UniqueCommandsResponse)
async def get_host_unique_commands(
host_id: str,
request: Request,
query: Optional[str] = None,
limit: int = 50,
current_user: dict = Depends(get_current_user),
current_user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get unique commands for a host (deduplicated).
Returns each unique command once with execution count and last used time.
Useful for command suggestions/autocomplete.
"""
"""Get unique commands for a host (deduplicated)."""
if not await _verify_history_access(host_id, request, db_session, current_user):
raise HTTPException(status_code=401, detail="Authentication required")
# Verify host exists
host_repo = HostRepository(db_session)
host = await host_repo.get(host_id)
@ -1413,6 +1538,7 @@ async def get_host_unique_commands(
command_hash=cmd["command_hash"],
last_used=cmd["last_used"],
execution_count=cmd["execution_count"],
is_pinned=cmd.get("is_pinned", False),
)
for cmd in unique_cmds
]
@ -1424,30 +1550,76 @@ async def get_host_unique_commands(
)
class TogglePinRequest(BaseModel):
is_pinned: bool
@router.post("/{host_id}/command-history/{command_hash}/pin")
async def toggle_command_pin(
host_id: str,
command_hash: str,
payload: TogglePinRequest,
request: Request,
current_user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
):
"""
Toggle the pinned status of a specific command across history.
"""
if not await _verify_history_access(host_id, request, db_session, current_user):
raise HTTPException(status_code=401, detail="Authentication required")
from sqlalchemy import update
from app.models.terminal_command_log import TerminalCommandLog
# Verify host
host_repo = HostRepository(db_session)
host = await host_repo.get(host_id)
if not host:
host = await host_repo.get_by_name(host_id)
if not host:
raise HTTPException(status_code=404, detail=f"Host '{host_id}' not found")
# Update pinned state for all occurrences of this command on this host
stmt = (
update(TerminalCommandLog)
.where(
TerminalCommandLog.host_id == host.id,
TerminalCommandLog.command_hash == command_hash
)
.values(is_pinned=payload.is_pinned)
)
result = await db_session.execute(stmt)
await db_session.commit()
return {"status": "success", "is_pinned": payload.is_pinned, "updated_count": result.rowcount}
@router.get("/command-history", response_model=CommandHistoryResponse)
async def get_global_command_history(
request: Request,
query: Optional[str] = None,
host_id: Optional[str] = None,
limit: int = 50,
offset: int = 0,
current_user: dict = Depends(get_current_user),
current_user: Optional[dict] = Depends(get_current_user_optional),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Get command history globally (across all hosts).
Args:
query: Optional search query to filter commands
host_id: Optional host ID to filter by
limit: Maximum number of results (default 50)
offset: Number of results to skip for pagination
Returns:
List of commands with timestamps and metadata
Requires JWT auth OR a valid terminal session token.
"""
user_id = current_user.get("user_id") or current_user.get("type", "api_key")
# Allow access with a session token (for pop-out windows)
if not current_user:
token = _get_session_token_from_request(request, "", token=request.query_params.get("token"))
if not token:
raise HTTPException(status_code=401, detail="Authentication required")
# Verify against any active session
session_repo = TerminalSessionRepository(db_session)
sessions = await session_repo.list_all_active()
verified = any(terminal_service.verify_token(token, s.token_hash) for s in sessions)
if not verified:
raise HTTPException(status_code=401, detail="Invalid session token")
user_id = current_user.get("user_id") or current_user.get("type", "api_key") if current_user else None
cmd_repo = TerminalCommandLogRepository(db_session)
logs = await cmd_repo.list_global(
query=query,
@ -1481,7 +1653,6 @@ async def clear_host_command_history(
host_id: str,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Clear command history for a specific host.
@ -1520,7 +1691,6 @@ async def purge_old_command_history(
days: int = 30,
current_user: dict = Depends(get_current_user),
db_session: AsyncSession = Depends(get_db),
debug_enabled: bool = Depends(require_debug_mode),
):
"""
Purge command history older than specified days.

View File

@ -19,6 +19,7 @@ class CommandHistoryItem(BaseModel):
host_name: Optional[str] = None
username: Optional[str] = None
execution_count: Optional[int] = None
is_pinned: bool = False
class Config:
from_attributes = True
@ -38,6 +39,7 @@ class UniqueCommandItem(BaseModel):
command_hash: str
last_used: datetime
execution_count: int
is_pinned: bool = False
class UniqueCommandsResponse(BaseModel):

View File

@ -149,6 +149,20 @@
.terminal-history-scope input {
width: 0.875rem; height: 0.875rem; 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 above the iframe */
.terminal-history-panel.docked {
position: relative !important; top: auto !important; left: auto !important;
width: 100% !important; max-height: 280px !important; z-index: auto !important;
box-shadow: none !important; border-bottom: 1px solid #374151;
}
.terminal-history-list {
flex: 1; overflow-y: auto; padding: 0.5rem;
scrollbar-width: thin; scrollbar-color: #374151 transparent;
@ -305,6 +319,12 @@
<input type="checkbox" id="terminalHistoryAllHosts" onchange="toggleHistoryScope()">
<span>Tous hôtes</span>
</label>
<button class="terminal-history-filter-btn" id="btnPinnedOnly" onclick="togglePinnedOnly()" title="Épinglées uniquement">
<i class="fas fa-thumbtack"></i>
</button>
<button class="terminal-history-filter-btn" id="btnHistoryPin" onclick="toggleHistoryPin()" title="Garder le panneau ouvert">
<i class="fas fa-map-pin"></i>
</button>
</div>
</div>
<div class="terminal-history-list" id="terminalHistoryList">

Binary file not shown.