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,
|
TerminalCommandLog.command_hash,
|
||||||
func.max(TerminalCommandLog.created_at).label("max_created"),
|
func.max(TerminalCommandLog.created_at).label("max_created"),
|
||||||
func.count(TerminalCommandLog.id).label("execution_count"),
|
func.count(TerminalCommandLog.id).label("execution_count"),
|
||||||
|
func.max(TerminalCommandLog.is_pinned).label("is_pinned"),
|
||||||
)
|
)
|
||||||
.where(and_(*conditions))
|
.where(and_(*conditions))
|
||||||
.group_by(TerminalCommandLog.command_hash)
|
.group_by(TerminalCommandLog.command_hash)
|
||||||
@ -214,13 +215,15 @@ class TerminalCommandLogRepository:
|
|||||||
TerminalCommandLog.command_hash,
|
TerminalCommandLog.command_hash,
|
||||||
subq.c.max_created,
|
subq.c.max_created,
|
||||||
subq.c.execution_count,
|
subq.c.execution_count,
|
||||||
|
subq.c.is_pinned,
|
||||||
)
|
)
|
||||||
.join(subq, and_(
|
.join(subq, and_(
|
||||||
TerminalCommandLog.command_hash == subq.c.command_hash,
|
TerminalCommandLog.command_hash == subq.c.command_hash,
|
||||||
TerminalCommandLog.created_at == subq.c.max_created,
|
TerminalCommandLog.created_at == subq.c.max_created,
|
||||||
))
|
))
|
||||||
.where(TerminalCommandLog.host_id == host_id)
|
.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)
|
.limit(limit)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -233,6 +236,7 @@ class TerminalCommandLogRepository:
|
|||||||
"command_hash": row.command_hash,
|
"command_hash": row.command_hash,
|
||||||
"last_used": row.max_created,
|
"last_used": row.max_created,
|
||||||
"execution_count": row.execution_count,
|
"execution_count": row.execution_count,
|
||||||
|
"is_pinned": bool(row.is_pinned),
|
||||||
}
|
}
|
||||||
for row in rows
|
for row in rows
|
||||||
]
|
]
|
||||||
|
|||||||
@ -129,6 +129,20 @@ class TerminalSessionRepository:
|
|||||||
)
|
)
|
||||||
return list(result.scalars().all())
|
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]:
|
async def list_all_active(self) -> List[TerminalSession]:
|
||||||
"""List all active sessions (for admin/GC)."""
|
"""List all active sessions (for admin/GC)."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|||||||
@ -3290,6 +3290,28 @@
|
|||||||
cursor: pointer;
|
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 {
|
.terminal-history-list {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
198
app/main.js
198
app/main.js
@ -105,6 +105,8 @@ class DashboardManager {
|
|||||||
|
|
||||||
// Terminal History Enhanced State
|
// Terminal History Enhanced State
|
||||||
this.terminalHistoryPanelOpen = false;
|
this.terminalHistoryPanelOpen = false;
|
||||||
|
this.terminalHistoryPanelPinned = false; // true = panel stays open (docked mode)
|
||||||
|
this.terminalHistoryPinnedOnly = false; // true = show only pinned commands
|
||||||
this.terminalHistorySelectedIndex = -1;
|
this.terminalHistorySelectedIndex = -1;
|
||||||
this.terminalHistoryTimeFilter = 'all'; // all, today, week, month
|
this.terminalHistoryTimeFilter = 'all'; // all, today, week, month
|
||||||
this.terminalHistoryStatusFilter = 'all'; // all, success, error
|
this.terminalHistoryStatusFilter = 'all'; // all, success, error
|
||||||
@ -11423,8 +11425,8 @@ class DashboardManager {
|
|||||||
|
|
||||||
const notification = document.createElement('div');
|
const notification = document.createElement('div');
|
||||||
notification.className = `fixed top-20 right-6 z-50 p-4 rounded-lg shadow-lg transition-all duration-300 ${type === 'success' ? 'bg-green-600' :
|
notification.className = `fixed top-20 right-6 z-50 p-4 rounded-lg shadow-lg transition-all duration-300 ${type === 'success' ? 'bg-green-600' :
|
||||||
type === 'warning' ? 'bg-yellow-600' :
|
type === 'warning' ? 'bg-yellow-600' :
|
||||||
type === 'error' ? 'bg-red-600' : 'bg-blue-600'
|
type === 'error' ? 'bg-red-600' : 'bg-blue-600'
|
||||||
} text-white`;
|
} text-white`;
|
||||||
notification.innerHTML = `
|
notification.innerHTML = `
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
@ -11789,6 +11791,12 @@ class DashboardManager {
|
|||||||
<input type="checkbox" id="terminalHistoryAllHosts" onchange="dashboard.toggleHistoryScope()">
|
<input type="checkbox" id="terminalHistoryAllHosts" onchange="dashboard.toggleHistoryScope()">
|
||||||
<span>Tous hôtes</span>
|
<span>Tous hôtes</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
<div class="terminal-history-list" id="terminalHistoryList">
|
<div class="terminal-history-list" id="terminalHistoryList">
|
||||||
@ -12285,6 +12293,9 @@ class DashboardManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
closeTerminalHistoryPanel() {
|
closeTerminalHistoryPanel() {
|
||||||
|
// If panel is pinned/docked, don't close on command execute
|
||||||
|
if (this.terminalHistoryPanelPinned) return;
|
||||||
|
|
||||||
const panel = document.getElementById('terminalHistoryPanel');
|
const panel = document.getElementById('terminalHistoryPanel');
|
||||||
const btn = document.getElementById('terminalHistoryBtn');
|
const btn = document.getElementById('terminalHistoryBtn');
|
||||||
|
|
||||||
@ -12320,56 +12331,34 @@ class DashboardManager {
|
|||||||
const hostId = this.terminalSession.host.id;
|
const hostId = this.terminalSession.host.id;
|
||||||
const timeFilter = this.terminalHistoryTimeFilter;
|
const timeFilter = this.terminalHistoryTimeFilter;
|
||||||
|
|
||||||
// Build query params
|
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('limit', '100');
|
params.set('limit', '100');
|
||||||
if (query) params.set('query', query);
|
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;
|
let endpoint;
|
||||||
if (allHosts) {
|
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()}`;
|
endpoint = `/api/terminal/command-history?${params.toString()}`;
|
||||||
} else {
|
} else {
|
||||||
// Use shell-history to fetch real commands from the remote host via SSH
|
// Per-host unique commands (includes command_hash for pinning)
|
||||||
endpoint = `/api/terminal/${hostId}/shell-history?${params.toString()}`;
|
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);
|
const response = await this.apiCall(endpoint);
|
||||||
let commands = response.commands || [];
|
let commands = response.commands || [];
|
||||||
|
|
||||||
// Client-side time filtering if API doesn't support it
|
// Client-side time filtering
|
||||||
if (timeFilter !== 'all') {
|
if (timeFilter !== 'all') {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
let cutoff;
|
let cutoff;
|
||||||
switch (timeFilter) {
|
switch (timeFilter) {
|
||||||
case 'today':
|
case 'today': cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate()); break;
|
||||||
cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
case 'week': cutoff = new Date(now.getTime() - 7 * 86400000); break;
|
||||||
break;
|
case 'month': cutoff = new Date(now.getTime() - 30 * 86400000); 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;
|
|
||||||
}
|
}
|
||||||
if (cutoff) {
|
if (cutoff) {
|
||||||
commands = commands.filter(cmd => {
|
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.terminalCommandHistory = commands;
|
||||||
this.terminalHistorySelectedIndex = -1;
|
this.terminalHistorySelectedIndex = -1;
|
||||||
|
|
||||||
this.renderTerminalHistory();
|
this.renderTerminalHistory();
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@ -12433,7 +12426,7 @@ class DashboardManager {
|
|||||||
ondblclick="dashboard.executeHistoryCommand(${index})"
|
ondblclick="dashboard.executeHistoryCommand(${index})"
|
||||||
title="${this.escapeHtml(command)}">
|
title="${this.escapeHtml(command)}">
|
||||||
<div class="terminal-history-cmd">
|
<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>
|
||||||
<div class="terminal-history-meta">
|
<div class="terminal-history-meta">
|
||||||
<span class="terminal-history-time" title="${new Date(cmd.last_used || cmd.created_at).toLocaleString('fr-FR')}">${timeAgo}</span>
|
<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>` : ''}
|
${hostName ? `<span class="terminal-history-host">${this.escapeHtml(hostName)}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="terminal-history-actions-inline">
|
<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">
|
<button class="terminal-history-action" onclick="event.stopPropagation(); dashboard.copyTerminalCommand(${index})" title="Copier">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
</button>
|
</button>
|
||||||
@ -12557,56 +12553,116 @@ class DashboardManager {
|
|||||||
this.loadTerminalCommandHistory(this.terminalHistorySearchQuery);
|
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) {
|
selectAndInsertHistoryCommand(index) {
|
||||||
const cmd = this.terminalCommandHistory[index];
|
const cmd = this.terminalCommandHistory[index];
|
||||||
if (!cmd) return;
|
if (!cmd) return;
|
||||||
|
|
||||||
const command = cmd.command || '';
|
const command = cmd.command || '';
|
||||||
|
|
||||||
// Copy to clipboard and show notification
|
const found = this._getTermFromIframe();
|
||||||
|
if (found) {
|
||||||
|
found.term.focus();
|
||||||
|
found.term.paste(command);
|
||||||
|
// Don't close panel (insert mode)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: postMessage to connect page
|
||||||
|
const iframe = document.getElementById('terminalIframe');
|
||||||
|
if (iframe && iframe.contentWindow) {
|
||||||
|
iframe.contentWindow.postMessage({ type: 'terminal:paste', text: command }, '*');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last resort: clipboard
|
||||||
this.copyTextToClipboard(command).then(() => {
|
this.copyTextToClipboard(command).then(() => {
|
||||||
this.showNotification('Commande copiée - Collez avec Ctrl+Shift+V', 'success');
|
this.showNotification('Commande copiée - Collez avec Ctrl+Shift+V', 'success');
|
||||||
|
}).catch(() => { });
|
||||||
// Best-effort log
|
|
||||||
this.logTerminalCommand(command);
|
|
||||||
|
|
||||||
// Close history panel and focus terminal
|
|
||||||
this.closeTerminalHistoryPanel();
|
|
||||||
|
|
||||||
// Focus the iframe
|
|
||||||
const iframe = document.getElementById('terminalIframe');
|
|
||||||
if (iframe && iframe.contentWindow) {
|
|
||||||
iframe.focus();
|
|
||||||
iframe.contentWindow.focus();
|
|
||||||
}
|
|
||||||
}).catch(() => {
|
|
||||||
this.showNotification('Commande: ' + command, 'info');
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
executeHistoryCommand(index) {
|
executeHistoryCommand(index) {
|
||||||
const cmd = this.terminalCommandHistory[index];
|
const cmd = this.terminalCommandHistory[index];
|
||||||
if (!cmd) return;
|
if (!cmd) return;
|
||||||
|
|
||||||
const command = cmd.command || '';
|
const command = cmd.command || '';
|
||||||
|
|
||||||
// Copy command + newline to execute it
|
const found = this._getTermFromIframe();
|
||||||
this.copyTextToClipboard(command + '\n').then(() => {
|
if (found) {
|
||||||
this.showNotification('Commande copiée avec Enter - Collez pour exécuter', 'success');
|
found.term.focus();
|
||||||
|
found.term.paste(command + '\r');
|
||||||
// Best-effort log
|
|
||||||
this.logTerminalCommand(command);
|
|
||||||
|
|
||||||
this.closeTerminalHistoryPanel();
|
this.closeTerminalHistoryPanel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const iframe = document.getElementById('terminalIframe');
|
// Fallback: postMessage to connect page
|
||||||
if (iframe && iframe.contentWindow) {
|
const iframe = document.getElementById('terminalIframe');
|
||||||
iframe.focus();
|
if (iframe && iframe.contentWindow) {
|
||||||
iframe.contentWindow.focus();
|
iframe.contentWindow.postMessage({ type: 'terminal:paste', text: command + '\r' }, '*');
|
||||||
}
|
this.closeTerminalHistoryPanel();
|
||||||
}).catch(() => {
|
return;
|
||||||
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) {
|
copyTerminalCommand(index) {
|
||||||
|
|||||||
@ -54,6 +54,8 @@ class TerminalCommandLog(Base):
|
|||||||
# Source identifier
|
# Source identifier
|
||||||
source: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'terminal'"))
|
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)
|
# If command was blocked (for audit - no raw command stored)
|
||||||
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
|
is_blocked: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("0"))
|
||||||
blocked_reason: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
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 datetime import datetime, timedelta, timezone
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.services.shell_history_service import shell_history_service, ShellHistoryError
|
from app.services.shell_history_service import shell_history_service, ShellHistoryError
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSocketDisconnect, status
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import settings
|
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"))
|
_templates = Jinja2Templates(directory=str(settings.base_dir / "templates"))
|
||||||
from app.crud.host import HostRepository
|
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")
|
referer = request.headers.get("referer")
|
||||||
if referer:
|
if referer:
|
||||||
try:
|
try:
|
||||||
referer_qs = parse_qs(urlparse(referer).query)
|
parsed = urlparse(referer)
|
||||||
referer_token = referer_qs.get("token", [None])[0]
|
qs = parse_qs(parsed.query)
|
||||||
if referer_token:
|
if "token" in qs:
|
||||||
return referer_token
|
return qs["token"][0]
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return None
|
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]:
|
def _get_session_token_from_websocket(websocket: WebSocket, session_id: str) -> Optional[str]:
|
||||||
token = websocket.query_params.get("token")
|
token = websocket.query_params.get("token")
|
||||||
if token:
|
if token:
|
||||||
@ -884,54 +917,76 @@ async def cleanup_terminal_sessions(
|
|||||||
"expired_marked": 0,
|
"expired_marked": 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class LogCommandRequest(BaseModel):
|
||||||
|
command: str
|
||||||
|
|
||||||
@router.post("/sessions/{session_id}/command")
|
@router.post("/sessions/{session_id}/command")
|
||||||
async def log_terminal_command(
|
async def log_terminal_command(
|
||||||
session_id: str,
|
session_id: str,
|
||||||
command_data: dict,
|
payload: LogCommandRequest,
|
||||||
request: Request,
|
request: Request,
|
||||||
current_user: dict = Depends(get_current_user),
|
|
||||||
db_session: AsyncSession = Depends(get_db),
|
db_session: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
session_repo = TerminalSessionRepository(db_session)
|
try:
|
||||||
session = await session_repo.get(session_id)
|
session_repo = TerminalSessionRepository(db_session)
|
||||||
|
session = await session_repo.get_active_by_id(session_id)
|
||||||
|
|
||||||
if not session:
|
if not session:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=404, detail="Session not found")
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
|
||||||
detail="Session not found"
|
# Verify session token
|
||||||
|
actual_token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token"))
|
||||||
|
if not actual_token or not terminal_service.verify_token(actual_token, session.token_hash):
|
||||||
|
raise HTTPException(status_code=403, detail="Invalid session token")
|
||||||
|
|
||||||
|
cmd = payload.command.strip()
|
||||||
|
if not cmd:
|
||||||
|
return {"status": "ignored", "reason": "empty_command"}
|
||||||
|
|
||||||
|
if len(cmd) > _MAX_TERMINAL_COMMAND_LENGTH:
|
||||||
|
cmd = cmd[:_MAX_TERMINAL_COMMAND_LENGTH]
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.security.command_policy import get_command_policy
|
||||||
|
policy = get_command_policy()
|
||||||
|
result = policy.evaluate(cmd)
|
||||||
|
|
||||||
|
# If not allowed and not blocked, we ignore it (UNKNOWN)
|
||||||
|
if not result.should_log and not result.is_blocked:
|
||||||
|
return {"status": "ignored", "reason": "not_allowed_to_log"}
|
||||||
|
|
||||||
|
is_blocked = result.is_blocked
|
||||||
|
reason = result.reason
|
||||||
|
masked_cmd = result.masked_command or ("[BLOCKED]" if is_blocked else cmd)
|
||||||
|
cmd_hash = result.command_hash or hashlib.sha256(masked_cmd.encode('utf-8')).hexdigest()
|
||||||
|
except Exception as pe:
|
||||||
|
logger.warning(f"Policy evaluation failure: {pe}")
|
||||||
|
is_blocked = False
|
||||||
|
reason = str(pe)
|
||||||
|
masked_cmd = cmd
|
||||||
|
cmd_hash = hashlib.sha256(cmd.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
cmd_repo = TerminalCommandLogRepository(db_session)
|
||||||
|
await cmd_repo.create(
|
||||||
|
host_id=session.host_id,
|
||||||
|
host_name=session.host_name,
|
||||||
|
user_id=session.user_id,
|
||||||
|
username=session.username,
|
||||||
|
terminal_session_id=session.id,
|
||||||
|
command=masked_cmd,
|
||||||
|
command_hash=cmd_hash,
|
||||||
|
source="terminal_dynamic",
|
||||||
|
is_blocked=is_blocked,
|
||||||
|
blocked_reason=reason,
|
||||||
)
|
)
|
||||||
|
|
||||||
token = _get_session_token_from_request(request, session_id, token=request.query_params.get("token"))
|
await db_session.commit()
|
||||||
if not token or not terminal_service.verify_token(token, session.token_hash):
|
return {"status": "success", "blocked": is_blocked}
|
||||||
raise HTTPException(
|
except HTTPException:
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
raise
|
||||||
detail="Invalid session token"
|
except Exception as e:
|
||||||
)
|
logger.error(f"Error logging terminal command: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
command = command_data.get("command")
|
|
||||||
if not command:
|
|
||||||
return {"status": "ignored", "reason": "empty_command"}
|
|
||||||
|
|
||||||
if len(command) > _MAX_TERMINAL_COMMAND_LENGTH:
|
|
||||||
command = command[:_MAX_TERMINAL_COMMAND_LENGTH]
|
|
||||||
|
|
||||||
cmd_repo = TerminalCommandLogRepository(db_session)
|
|
||||||
command_hash = hashlib.sha256(command.encode()).hexdigest()
|
|
||||||
|
|
||||||
await cmd_repo.create(
|
|
||||||
host_id=session.host_id,
|
|
||||||
host_name=session.host_name,
|
|
||||||
user_id=session.user_id,
|
|
||||||
username=session.username,
|
|
||||||
terminal_session_id=session.id,
|
|
||||||
command=command,
|
|
||||||
command_hash=command_hash,
|
|
||||||
source="terminal_interactive"
|
|
||||||
)
|
|
||||||
|
|
||||||
await db_session.commit()
|
|
||||||
|
|
||||||
return {"status": "logged", "command_len": len(command)}
|
|
||||||
|
|
||||||
@router.get("/connect/{session_id}")
|
@router.get("/connect/{session_id}")
|
||||||
async def get_terminal_connect_page(
|
async def get_terminal_connect_page(
|
||||||
@ -1049,20 +1104,24 @@ async def get_terminal_connect_page(
|
|||||||
)
|
)
|
||||||
|
|
||||||
history_js = """
|
history_js = """
|
||||||
let historyPanelOpen = false, historySelectedIndex = -1, historySearchQuery = '', historyTimeFilter = 'all';
|
// ---- History panel state ----
|
||||||
async function toggleHistory() {
|
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 panel = document.getElementById('terminalHistoryPanel');
|
||||||
const btn = document.getElementById('btnHistory');
|
const btn = document.getElementById('btnHistory');
|
||||||
if (historyPanelOpen) { closeHistoryPanel(); }
|
panel.style.display = 'flex'; panel.classList.add('open'); btn.classList.add('active');
|
||||||
else {
|
historyPanelOpen = true; historySelectedIndex = -1;
|
||||||
panel.style.display = 'flex'; panel.classList.add('open'); btn.classList.add('active');
|
const si = document.getElementById('terminalHistorySearch');
|
||||||
historyPanelOpen = true; historySelectedIndex = -1;
|
if (si) { si.focus(); si.select(); }
|
||||||
const si = document.getElementById('terminalHistorySearch');
|
if (historyData.length === 0) loadHistory();
|
||||||
if (si) { si.focus(); si.select(); }
|
|
||||||
if (historyData.length === 0) loadHistory();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function closeHistoryPanel() {
|
function closeHistoryPanel() {
|
||||||
|
if (historyPanelPinned) return;
|
||||||
const panel = document.getElementById('terminalHistoryPanel');
|
const panel = document.getElementById('terminalHistoryPanel');
|
||||||
const btn = document.getElementById('btnHistory');
|
const btn = document.getElementById('btnHistory');
|
||||||
panel.classList.remove('open'); btn.classList.remove('active');
|
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);
|
setTimeout(() => { try { panel.style.display = 'none'; } catch(e){} }, 200);
|
||||||
document.getElementById('terminalFrame').focus();
|
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() {
|
async function loadHistory() {
|
||||||
const list = document.getElementById('terminalHistoryList');
|
const list = document.getElementById('terminalHistoryList');
|
||||||
list.innerHTML = '<div class="terminal-history-loading"><i class="fas fa-spinner fa-spin"></i> Chargement...</div>';
|
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 allHosts = document.getElementById('terminalHistoryAllHosts')?.checked || false;
|
||||||
const query = historySearchQuery;
|
const query = historySearchQuery;
|
||||||
let ep = allHosts ? '/api/terminal/command-history?limit=100' : `/api/terminal/${HOST_ID}/shell-history?limit=100`;
|
let ep = allHosts
|
||||||
if (query) ep += (ep.includes('?') ? '&' : '?') + 'query=' + encodeURIComponent(query);
|
? `/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 {
|
try {
|
||||||
const res = await fetch(ep, { headers: { 'Authorization': 'Bearer ' + TOKEN } });
|
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();
|
const data = await res.json();
|
||||||
let cmds = data.commands || [];
|
let cmds = data.commands || [];
|
||||||
if (historyTimeFilter !== 'all') {
|
if (historyTimeFilter !== 'all') {
|
||||||
@ -1089,38 +1166,82 @@ async def get_terminal_connect_page(
|
|||||||
else if (historyTimeFilter === 'month') cutoff = new Date(now.getTime() - 30*86400000);
|
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 (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();
|
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 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'});}
|
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(){
|
function renderHistory(){
|
||||||
const list=document.getElementById('terminalHistoryList'),q=historySearchQuery||'';
|
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)=>{
|
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;
|
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>60?c.substring(0,60)+'...':c);
|
let dc=escH(c.length>80?c.substring(0,80)+'...':c);
|
||||||
if(q)dc=dc.replace(new RegExp(`(${escRE(q)})`,'gi'),'<mark>$1</mark>');
|
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('');
|
}).join('');
|
||||||
if(historySelectedIndex>=0){const s=list.querySelector('.selected');if(s)s.scrollIntoView({block:'nearest',behavior:'smooth'});}
|
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;
|
let _st=null;
|
||||||
function searchHistory(q){historySearchQuery=q;historySelectedIndex=-1;if(_st)clearTimeout(_st);_st=setTimeout(()=>loadHistory(),250);}
|
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 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 setHistoryTimeFilter(v){historyTimeFilter=v;historySelectedIndex=-1;loadHistory();}
|
||||||
function toggleHistoryScope(){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)=>{
|
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();}
|
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_block = (
|
||||||
"<script>\n"
|
"<script>\n"
|
||||||
f" const SESSION_ID = {js_session_id};\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 frame = document.getElementById('terminalFrame');\n"
|
||||||
" const loading = document.getElementById('terminalLoading');\n"
|
" const loading = document.getElementById('terminalLoading');\n"
|
||||||
" if (!frame) return;\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"
|
" const qs = new URLSearchParams();\n"
|
||||||
" qs.set('token', TOKEN);\n"
|
" qs.set('token', TOKEN);\n"
|
||||||
" ttydUrl = '/api/terminal/proxy/' + encodeURIComponent(SESSION_ID) + '/?' + qs.toString();\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)
|
@router.get("/{host_id}/command-history", response_model=CommandHistoryResponse)
|
||||||
async def get_host_command_history(
|
async def get_host_command_history(
|
||||||
host_id: str,
|
host_id: str,
|
||||||
|
request: Request,
|
||||||
query: Optional[str] = None,
|
query: Optional[str] = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
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),
|
db_session: AsyncSession = Depends(get_db),
|
||||||
debug_enabled: bool = Depends(require_debug_mode),
|
|
||||||
):
|
):
|
||||||
"""
|
"""Get command history for a specific host."""
|
||||||
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")
|
||||||
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
|
|
||||||
"""
|
|
||||||
# Verify host exists
|
# Verify host exists
|
||||||
host_repo = HostRepository(db_session)
|
host_repo = HostRepository(db_session)
|
||||||
host = await host_repo.get(host_id)
|
host = await host_repo.get(host_id)
|
||||||
@ -1301,26 +1438,17 @@ async def get_host_command_history(
|
|||||||
@router.get("/{host_id}/shell-history")
|
@router.get("/{host_id}/shell-history")
|
||||||
async def get_host_shell_history(
|
async def get_host_shell_history(
|
||||||
host_id: str,
|
host_id: str,
|
||||||
|
request: Request,
|
||||||
query: Optional[str] = None,
|
query: Optional[str] = None,
|
||||||
limit: int = 100,
|
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),
|
db_session: AsyncSession = Depends(get_db),
|
||||||
debug_enabled: bool = Depends(require_debug_mode),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get shell history directly from the remote host via SSH.
|
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
|
# Verify host exists
|
||||||
host_repo = HostRepository(db_session)
|
host_repo = HostRepository(db_session)
|
||||||
host = await host_repo.get(host_id)
|
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)
|
@router.get("/{host_id}/command-history/unique", response_model=UniqueCommandsResponse)
|
||||||
async def get_host_unique_commands(
|
async def get_host_unique_commands(
|
||||||
host_id: str,
|
host_id: str,
|
||||||
|
request: Request,
|
||||||
query: Optional[str] = None,
|
query: Optional[str] = None,
|
||||||
limit: int = 50,
|
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),
|
db_session: AsyncSession = Depends(get_db),
|
||||||
debug_enabled: bool = Depends(require_debug_mode),
|
|
||||||
):
|
):
|
||||||
"""
|
"""Get unique commands for a host (deduplicated)."""
|
||||||
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")
|
||||||
Returns each unique command once with execution count and last used time.
|
|
||||||
Useful for command suggestions/autocomplete.
|
|
||||||
"""
|
|
||||||
# Verify host exists
|
# Verify host exists
|
||||||
host_repo = HostRepository(db_session)
|
host_repo = HostRepository(db_session)
|
||||||
host = await host_repo.get(host_id)
|
host = await host_repo.get(host_id)
|
||||||
@ -1413,6 +1538,7 @@ async def get_host_unique_commands(
|
|||||||
command_hash=cmd["command_hash"],
|
command_hash=cmd["command_hash"],
|
||||||
last_used=cmd["last_used"],
|
last_used=cmd["last_used"],
|
||||||
execution_count=cmd["execution_count"],
|
execution_count=cmd["execution_count"],
|
||||||
|
is_pinned=cmd.get("is_pinned", False),
|
||||||
)
|
)
|
||||||
for cmd in unique_cmds
|
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)
|
@router.get("/command-history", response_model=CommandHistoryResponse)
|
||||||
async def get_global_command_history(
|
async def get_global_command_history(
|
||||||
|
request: Request,
|
||||||
query: Optional[str] = None,
|
query: Optional[str] = None,
|
||||||
host_id: Optional[str] = None,
|
host_id: Optional[str] = None,
|
||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
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),
|
db_session: AsyncSession = Depends(get_db),
|
||||||
debug_enabled: bool = Depends(require_debug_mode),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get command history globally (across all hosts).
|
Get command history globally (across all hosts).
|
||||||
|
Requires JWT auth OR a valid terminal session token.
|
||||||
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
|
|
||||||
"""
|
"""
|
||||||
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)
|
cmd_repo = TerminalCommandLogRepository(db_session)
|
||||||
logs = await cmd_repo.list_global(
|
logs = await cmd_repo.list_global(
|
||||||
query=query,
|
query=query,
|
||||||
@ -1481,7 +1653,6 @@ async def clear_host_command_history(
|
|||||||
host_id: str,
|
host_id: str,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
db_session: AsyncSession = Depends(get_db),
|
db_session: AsyncSession = Depends(get_db),
|
||||||
debug_enabled: bool = Depends(require_debug_mode),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Clear command history for a specific host.
|
Clear command history for a specific host.
|
||||||
@ -1520,7 +1691,6 @@ async def purge_old_command_history(
|
|||||||
days: int = 30,
|
days: int = 30,
|
||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
db_session: AsyncSession = Depends(get_db),
|
db_session: AsyncSession = Depends(get_db),
|
||||||
debug_enabled: bool = Depends(require_debug_mode),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Purge command history older than specified days.
|
Purge command history older than specified days.
|
||||||
|
|||||||
@ -19,6 +19,7 @@ class CommandHistoryItem(BaseModel):
|
|||||||
host_name: Optional[str] = None
|
host_name: Optional[str] = None
|
||||||
username: Optional[str] = None
|
username: Optional[str] = None
|
||||||
execution_count: Optional[int] = None
|
execution_count: Optional[int] = None
|
||||||
|
is_pinned: bool = False
|
||||||
|
|
||||||
class Config:
|
class Config:
|
||||||
from_attributes = True
|
from_attributes = True
|
||||||
@ -38,6 +39,7 @@ class UniqueCommandItem(BaseModel):
|
|||||||
command_hash: str
|
command_hash: str
|
||||||
last_used: datetime
|
last_used: datetime
|
||||||
execution_count: int
|
execution_count: int
|
||||||
|
is_pinned: bool = False
|
||||||
|
|
||||||
|
|
||||||
class UniqueCommandsResponse(BaseModel):
|
class UniqueCommandsResponse(BaseModel):
|
||||||
|
|||||||
@ -149,6 +149,20 @@
|
|||||||
.terminal-history-scope input {
|
.terminal-history-scope input {
|
||||||
width: 0.875rem; height: 0.875rem; accent-color: #7c3aed; cursor: pointer;
|
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 {
|
.terminal-history-list {
|
||||||
flex: 1; overflow-y: auto; padding: 0.5rem;
|
flex: 1; overflow-y: auto; padding: 0.5rem;
|
||||||
scrollbar-width: thin; scrollbar-color: #374151 transparent;
|
scrollbar-width: thin; scrollbar-color: #374151 transparent;
|
||||||
@ -305,6 +319,12 @@
|
|||||||
<input type="checkbox" id="terminalHistoryAllHosts" onchange="toggleHistoryScope()">
|
<input type="checkbox" id="terminalHistoryAllHosts" onchange="toggleHistoryScope()">
|
||||||
<span>Tous hôtes</span>
|
<span>Tous hôtes</span>
|
||||||
</label>
|
</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>
|
</div>
|
||||||
<div class="terminal-history-list" id="terminalHistoryList">
|
<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