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
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:
@@ -139,6 +139,8 @@ class TerminalCommandLogRepository:
|
||||
async def list_global(
|
||||
self,
|
||||
query: Optional[str] = None,
|
||||
time_filter: str = "all",
|
||||
pinned_only: bool = False,
|
||||
host_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
@@ -146,13 +148,6 @@ class TerminalCommandLogRepository:
|
||||
) -> 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]
|
||||
|
||||
@@ -164,6 +159,23 @@ class TerminalCommandLogRepository:
|
||||
|
||||
if query:
|
||||
conditions.append(TerminalCommandLog.command.ilike(f"%{query}%"))
|
||||
|
||||
if pinned_only:
|
||||
conditions.append(TerminalCommandLog.is_pinned == True)
|
||||
|
||||
if time_filter != "all":
|
||||
now = datetime.now(timezone.utc)
|
||||
if time_filter == "today":
|
||||
cutoff = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
elif time_filter == "week":
|
||||
cutoff = now - timedelta(days=7)
|
||||
elif time_filter == "month":
|
||||
cutoff = now - timedelta(days=30)
|
||||
else:
|
||||
cutoff = None
|
||||
|
||||
if cutoff:
|
||||
conditions.append(TerminalCommandLog.created_at >= cutoff)
|
||||
|
||||
stmt = (
|
||||
select(TerminalCommandLog)
|
||||
|
||||
+24
-4
@@ -3167,20 +3167,25 @@
|
||||
|
||||
/* Terminal History Panel */
|
||||
.terminal-history-panel {
|
||||
position: absolute;
|
||||
top: 57px; /* Matches header height */
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: #1e1e2e;
|
||||
border-bottom: 1px solid #374151;
|
||||
max-height: 350px;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
transition: max-height 0.3s ease, opacity 0.2s ease, transform 0.2s ease;
|
||||
box-shadow: 0 5px 25px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.terminal-history-panel.open {
|
||||
max-height: 350px;
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.terminal-history-header {
|
||||
@@ -3308,7 +3313,11 @@
|
||||
|
||||
/* Docked mode: panel stays visible and doesn't close on command execute */
|
||||
.terminal-history-panel.docked {
|
||||
position: relative;
|
||||
top: 0;
|
||||
max-height: 280px;
|
||||
z-index: 1;
|
||||
box-shadow: none;
|
||||
border-bottom: 2px solid #7c3aed;
|
||||
}
|
||||
|
||||
@@ -3395,6 +3404,17 @@
|
||||
color: #6b7280;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.terminal-history-host {
|
||||
font-size: 0.6875rem;
|
||||
color: #4ade80;
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
margin-left: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.terminal-history-count {
|
||||
font-size: 0.625rem;
|
||||
|
||||
+11
-7
@@ -11726,6 +11726,8 @@ class DashboardManager {
|
||||
|
||||
showTerminalDrawer(hostName, hostIp, loading = false) {
|
||||
this.terminalDrawerOpen = true;
|
||||
this.terminalHistoryPanelOpen = false;
|
||||
this.terminalHistoryPanelPinned = false;
|
||||
|
||||
// Create drawer if it doesn't exist
|
||||
let drawer = document.getElementById('terminalDrawer');
|
||||
@@ -11963,6 +11965,8 @@ class DashboardManager {
|
||||
}
|
||||
|
||||
this.terminalDrawerOpen = false;
|
||||
this.terminalHistoryPanelOpen = false;
|
||||
this.terminalHistoryPanelPinned = false;
|
||||
}
|
||||
|
||||
// ===== TERMINAL CLEANUP HANDLERS =====
|
||||
@@ -12260,7 +12264,7 @@ class DashboardManager {
|
||||
|
||||
async toggleTerminalHistory() {
|
||||
if (this.terminalHistoryPanelOpen) {
|
||||
this.closeTerminalHistoryPanel();
|
||||
this.closeTerminalHistoryPanel(true);
|
||||
} else {
|
||||
this.openTerminalHistoryPanel();
|
||||
}
|
||||
@@ -12292,9 +12296,9 @@ class DashboardManager {
|
||||
}
|
||||
}
|
||||
|
||||
closeTerminalHistoryPanel() {
|
||||
// If panel is pinned/docked, don't close on command execute
|
||||
if (this.terminalHistoryPanelPinned) return;
|
||||
closeTerminalHistoryPanel(force = false) {
|
||||
// If panel is pinned/docked, don't close on command execute unless forced
|
||||
if (this.terminalHistoryPanelPinned && !force) return;
|
||||
|
||||
const panel = document.getElementById('terminalHistoryPanel');
|
||||
const btn = document.getElementById('terminalHistoryBtn');
|
||||
@@ -12577,8 +12581,8 @@ class DashboardManager {
|
||||
const newPinned = !cmd.is_pinned;
|
||||
|
||||
try {
|
||||
const hostId = this.terminalSession.host.id;
|
||||
await this.apiCall(`/api/terminal/${hostId}/command-history/${cmd.command_hash}/pin`, {
|
||||
const hId = cmd.host_id || this.terminalSession.host.id;
|
||||
await this.apiCall(`/api/terminal/${hId}/command-history/${cmd.command_hash}/pin`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ is_pinned: newPinned })
|
||||
});
|
||||
@@ -12620,7 +12624,7 @@ class DashboardManager {
|
||||
if (found) {
|
||||
found.term.focus();
|
||||
found.term.paste(command);
|
||||
// Don't close panel (insert mode)
|
||||
this.closeTerminalHistoryPanel();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
+22
-10
@@ -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
|
||||
]
|
||||
|
||||
@@ -15,7 +15,9 @@ class CommandHistoryItem(BaseModel):
|
||||
"""A single command from history."""
|
||||
id: int
|
||||
command: str
|
||||
command_hash: Optional[str] = None
|
||||
created_at: datetime
|
||||
host_id: Optional[str] = None
|
||||
host_name: Optional[str] = None
|
||||
username: Optional[str] = None
|
||||
execution_count: Optional[int] = None
|
||||
|
||||
@@ -191,6 +191,12 @@
|
||||
}
|
||||
.terminal-history-meta { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; }
|
||||
.terminal-history-time { font-size: 0.6875rem; color: #6b7280; }
|
||||
.terminal-history-host {
|
||||
font-size: 0.6875rem; color: #4ade80;
|
||||
background: rgba(34, 197, 94, 0.1); padding: 0.125rem 0.375rem;
|
||||
border-radius: 4px; border: 1px solid rgba(34, 197, 94, 0.2);
|
||||
margin-left: auto; white-space: nowrap;
|
||||
}
|
||||
.terminal-history-count {
|
||||
font-size: 0.625rem; color: #7c3aed;
|
||||
background: rgba(124, 58, 237, 0.2); padding: 0.125rem 0.375rem;
|
||||
@@ -289,11 +295,6 @@
|
||||
{% if debug_panel_html %}
|
||||
{{ debug_panel_html | safe }}
|
||||
{% endif %}
|
||||
<iframe
|
||||
id="terminalFrame"
|
||||
src="about:blank"
|
||||
allow="clipboard-read; clipboard-write; clipboard-write-text"
|
||||
></iframe>
|
||||
<div class="terminal-history-panel" id="terminalHistoryPanel" style="display: none;">
|
||||
<div class="terminal-history-header">
|
||||
<div class="terminal-history-search">
|
||||
@@ -338,6 +339,11 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<iframe
|
||||
id="terminalFrame"
|
||||
src="about:blank"
|
||||
allow="clipboard-read; clipboard-write; clipboard-write-text"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
{{ script_block | safe }}
|
||||
|
||||
Reference in New Issue
Block a user