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
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:
parent
88742892d0
commit
d29eefcef4
42
alembic/versions/0020_add_is_pinned_to_terminal.py
Normal file
42
alembic/versions/0020_add_is_pinned_to_terminal.py
Normal 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
20
alembic_history.txt
Normal 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
97
alembic_out.txt
Normal 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
|
||||
@ -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
|
||||
]
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
184
app/main.js
184
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
|
||||
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,'&').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=`<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.
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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">
|
||||
|
||||
BIN
data/homelab.db
BIN
data/homelab.db
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user