feat: Implement web-based SSH terminal with session management, command logging, and host interaction.
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:
2026-03-03 13:37:52 -05:00
parent d29eefcef4
commit 57bfe02e32
10 changed files with 245 additions and 33 deletions
+22 -10
View File
@@ -202,9 +202,8 @@ async def _verify_history_access(
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
# Try query param first (generic)
q_token = request.query_params.get("token")
session_repo = TerminalSessionRepository(db_session)
# Try by ID first
@@ -218,7 +217,9 @@ async def _verify_history_access(
sessions = await session_repo.list_active_for_host(host.id)
for s in sessions:
if terminal_service.verify_token(token, s.token_hash):
# Check if token is in query param OR in the session-specific cookie
token = q_token or _get_session_token_from_request(request, s.id)
if token and terminal_service.verify_token(token, s.token_hash):
return True
return False
@@ -1111,7 +1112,7 @@ async def get_terminal_connect_page(
// ---- Panel open/close ----
function toggleHistory() {
if (historyPanelOpen && !historyPanelPinned) { closeHistoryPanel(); return; }
if (historyPanelOpen) { closeHistoryPanel(true); return; }
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('btnHistory');
panel.style.display = 'flex'; panel.classList.add('open'); btn.classList.add('active');
@@ -1120,8 +1121,8 @@ async def get_terminal_connect_page(
if (si) { si.focus(); si.select(); }
if (historyData.length === 0) loadHistory();
}
function closeHistoryPanel() {
if (historyPanelPinned) return;
function closeHistoryPanel(force = false) {
if (historyPanelPinned && !force) return;
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('btnHistory');
panel.classList.remove('open'); btn.classList.remove('active');
@@ -1174,8 +1175,9 @@ async def get_terminal_connect_page(
const cmd = historyData[i];
if (!cmd || !cmd.command_hash) return;
const newPinned = !cmd.is_pinned;
const hId = cmd.host_id || HOST_ID;
try {
const res = await fetch(`/api/terminal/${HOST_ID}/command-history/${cmd.command_hash}/pin?token=${encodeURIComponent(TOKEN)}`, {
const res = await fetch(`/api/terminal/${hId}/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 })
@@ -1214,10 +1216,10 @@ async def get_terminal_connect_page(
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)}"`: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,pinned=cmd.is_pinned;
const c=cmd.command||'',ta=relTime(cmd.last_used||cmd.created_at),ec=cmd.execution_count||1,sel=i===historySelectedIndex,pinned=cmd.is_pinned,hn=cmd.host_name||'';
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="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>`;
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>`:''}${hn?`<span class="terminal-history-host">${escH(hn)}</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'});}
}
@@ -1420,9 +1422,12 @@ async def get_host_command_history(
CommandHistoryItem(
id=log.id,
command=log.command,
command_hash=log.command_hash,
created_at=log.created_at,
host_id=log.host_id,
host_name=log.host_name,
username=log.username,
is_pinned=log.is_pinned,
)
for log in logs
]
@@ -1597,6 +1602,8 @@ async def toggle_command_pin(
async def get_global_command_history(
request: Request,
query: Optional[str] = None,
time_filter: str = "all",
pinned_only: bool = False,
host_id: Optional[str] = None,
limit: int = 50,
offset: int = 0,
@@ -1623,6 +1630,8 @@ async def get_global_command_history(
cmd_repo = TerminalCommandLogRepository(db_session)
logs = await cmd_repo.list_global(
query=query,
time_filter=time_filter,
pinned_only=pinned_only,
host_id=host_id,
user_id=str(user_id) if user_id else None,
limit=min(limit, 100),
@@ -1633,9 +1642,12 @@ async def get_global_command_history(
CommandHistoryItem(
id=log.id,
command=log.command,
command_hash=log.command_hash,
created_at=log.created_at,
host_id=log.host_id,
host_name=log.host_name,
username=log.username,
is_pinned=log.is_pinned,
)
for log in logs
]