Add enhanced terminal history panel UI with animations, keyboard navigation, advanced filtering, search highlighting, and improved storage metrics display with detailed filesystem tables and ZFS/LVM support
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
Tests / Backend Tests (Python) (3.10) (push) Has been cancelled
Tests / Backend Tests (Python) (3.11) (push) Has been cancelled
Tests / Backend Tests (Python) (3.12) (push) Has been cancelled
Tests / Frontend Tests (JS) (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / All Tests Passed (push) Has been cancelled
This commit is contained in:
@@ -102,6 +102,18 @@ BUILTIN_PLAYBOOKS: Dict[str, BuiltinPlaybookDefinition] = {
|
||||
schedule_enabled=True,
|
||||
visible_in_ui=True,
|
||||
),
|
||||
"collect_storage_details": BuiltinPlaybookDefinition(
|
||||
id="collect_storage_details",
|
||||
name="Stockage Détaillé",
|
||||
description="Collecte les informations de stockage détaillées (disques, partitions, mounts, ZFS, LVM)",
|
||||
playbook_file="_builtin_collect_storage_details.yml",
|
||||
category="metrics",
|
||||
icon="fas fa-database",
|
||||
color="indigo",
|
||||
collect_metrics=True,
|
||||
schedule_enabled=True,
|
||||
visible_in_ui=True,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -400,6 +412,7 @@ class BuiltinPlaybookService:
|
||||
"collect_memory_info": "memory",
|
||||
"collect_cpu_info": "cpu",
|
||||
"collect_network_info": "network",
|
||||
"collect_storage_details": "storage_details",
|
||||
}
|
||||
|
||||
metric_type = metric_type_map.get(builtin_id, "unknown")
|
||||
@@ -444,6 +457,9 @@ class BuiltinPlaybookService:
|
||||
lvm_info=parsed_data.get("lvm_info"),
|
||||
zfs_info=parsed_data.get("zfs_info"),
|
||||
|
||||
# Stockage détaillé (nouvelle collecte)
|
||||
storage_details=parsed_data.get("storage_details"),
|
||||
|
||||
# System
|
||||
os_name=self._clean_string_value(parsed_data.get("os_name")),
|
||||
os_version=self._clean_string_value(parsed_data.get("os_version")),
|
||||
@@ -512,6 +528,9 @@ class BuiltinPlaybookService:
|
||||
lvm_info=getattr(metrics, "lvm_info", None),
|
||||
zfs_info=getattr(metrics, "zfs_info", None),
|
||||
|
||||
# Stockage détaillé
|
||||
storage_details=getattr(metrics, "storage_details", None),
|
||||
|
||||
# System
|
||||
os_name=metrics.os_name,
|
||||
uptime_human=metrics.uptime_human,
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
"""
|
||||
Service de récupération de l'historique shell depuis les hôtes distants via SSH.
|
||||
|
||||
Ce service permet de récupérer le fichier ~/.bash_history des hôtes
|
||||
pour afficher les commandes exécutées dans le terminal.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from typing import Dict, List, Optional, Any
|
||||
|
||||
try:
|
||||
import asyncssh
|
||||
_asyncssh_import_error: Optional[BaseException] = None
|
||||
except ModuleNotFoundError as e:
|
||||
asyncssh = None
|
||||
_asyncssh_import_error = e
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger("homelab.shell_history")
|
||||
|
||||
# Log prefix for standardized output
|
||||
LOG_PREFIX = "[SHELL_HISTORY]"
|
||||
|
||||
|
||||
class ShellHistoryError(Exception):
|
||||
"""Error fetching shell history."""
|
||||
pass
|
||||
|
||||
|
||||
class ShellHistoryService:
|
||||
"""Service for fetching shell command history from remote hosts via SSH."""
|
||||
|
||||
def __init__(self):
|
||||
self.ssh_key_path = settings.ssh_key_path
|
||||
self.ssh_user = settings.ssh_user
|
||||
self.ssh_remote_user = settings.ssh_remote_user
|
||||
self.connect_timeout = 5
|
||||
self.exec_timeout = 10
|
||||
|
||||
async def _run_ssh_subprocess(self, host_ip: str, username: str, remote_cmd: str) -> str:
|
||||
"""Run a remote command via system ssh as a fallback when asyncssh isn't installed."""
|
||||
ssh_bin = shutil.which("ssh")
|
||||
if not ssh_bin:
|
||||
raise ShellHistoryError("Missing dependency: ssh binary not found in PATH")
|
||||
|
||||
key_path = self.ssh_key_path
|
||||
if not key_path or not os.path.exists(key_path):
|
||||
raise ShellHistoryError(
|
||||
f"SSH key not found at '{key_path}'. Configure SSH_KEY_PATH / settings.ssh_key_path."
|
||||
)
|
||||
|
||||
args = [
|
||||
ssh_bin,
|
||||
"-i",
|
||||
key_path,
|
||||
"-o",
|
||||
"BatchMode=yes",
|
||||
"-o",
|
||||
f"ConnectTimeout={self.connect_timeout}",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
f"{username}@{host_ip}",
|
||||
remote_cmd,
|
||||
]
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(),
|
||||
timeout=self.connect_timeout + self.exec_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError as e:
|
||||
raise ShellHistoryError(f"Timeout reading history from {host_ip}") from e
|
||||
except FileNotFoundError as e:
|
||||
raise ShellHistoryError("ssh binary not available") from e
|
||||
|
||||
if proc.returncode not in (0, None):
|
||||
err = (stderr or b"").decode(errors="ignore").strip()
|
||||
if not err:
|
||||
err = (stdout or b"").decode(errors="ignore").strip()
|
||||
raise ShellHistoryError(f"SSH error connecting to {host_ip}: {err or proc.returncode}")
|
||||
|
||||
return (stdout or b"").decode(errors="ignore")
|
||||
|
||||
async def _ssh_connect(self, host_ip: str) -> "asyncssh.SSHClientConnection":
|
||||
"""Establish SSH connection to a host."""
|
||||
if asyncssh is None:
|
||||
err = None
|
||||
try:
|
||||
err = str(_asyncssh_import_error) if _asyncssh_import_error else None
|
||||
except Exception:
|
||||
err = None
|
||||
raise ShellHistoryError(
|
||||
"Missing dependency: asyncssh. Install it to enable shell history retrieval. "
|
||||
f"(python={sys.executable}, import_error={err})"
|
||||
)
|
||||
|
||||
tried_users: List[str] = []
|
||||
last_err: Optional[BaseException] = None
|
||||
|
||||
for username in [self.ssh_remote_user, self.ssh_user]:
|
||||
if not username or username in tried_users:
|
||||
continue
|
||||
tried_users.append(username)
|
||||
try:
|
||||
conn = await asyncio.wait_for(
|
||||
asyncssh.connect(
|
||||
host_ip,
|
||||
username=username,
|
||||
client_keys=[self.ssh_key_path],
|
||||
known_hosts=None,
|
||||
),
|
||||
timeout=self.connect_timeout
|
||||
)
|
||||
return conn
|
||||
except asyncio.TimeoutError as e:
|
||||
last_err = e
|
||||
continue
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
continue
|
||||
|
||||
if isinstance(last_err, asyncio.TimeoutError):
|
||||
raise ShellHistoryError(f"SSH connection timeout to {host_ip}")
|
||||
raise ShellHistoryError(f"SSH error connecting to {host_ip}: {last_err}")
|
||||
|
||||
async def fetch_bash_history(
|
||||
self,
|
||||
host_ip: str,
|
||||
limit: int = 100,
|
||||
user: str = "automation"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch bash history from a remote host.
|
||||
|
||||
Args:
|
||||
host_ip: IP address of the host
|
||||
limit: Maximum number of commands to return
|
||||
user: User whose history to fetch (default: automation)
|
||||
|
||||
Returns:
|
||||
List of command dicts with 'command', 'command_hash', 'line_number'
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
# Read .bash_history file (last N lines, reversed for recent first)
|
||||
# Force bash to write history by sourcing .bashrc or use history -a
|
||||
# The simplest approach: just read the file
|
||||
cmd = f"tail -n {limit * 2} ~{user}/.bash_history 2>/dev/null || tail -n {limit * 2} ~/.bash_history 2>/dev/null || echo ''"
|
||||
|
||||
if asyncssh is None:
|
||||
output = ""
|
||||
tried_users: List[str] = []
|
||||
last_err: Optional[BaseException] = None
|
||||
for login_user in [self.ssh_remote_user, self.ssh_user]:
|
||||
if not login_user or login_user in tried_users:
|
||||
continue
|
||||
tried_users.append(login_user)
|
||||
try:
|
||||
output = await self._run_ssh_subprocess(host_ip, login_user, cmd)
|
||||
last_err = None
|
||||
break
|
||||
except ShellHistoryError as e:
|
||||
last_err = e
|
||||
continue
|
||||
if last_err is not None and not output:
|
||||
raise last_err
|
||||
else:
|
||||
conn = await self._ssh_connect(host_ip)
|
||||
result = await asyncio.wait_for(
|
||||
conn.run(cmd, check=False),
|
||||
timeout=self.exec_timeout
|
||||
)
|
||||
output = result.stdout or ""
|
||||
|
||||
lines = output.strip().split('\n')
|
||||
|
||||
# Filter and deduplicate
|
||||
seen_hashes = set()
|
||||
commands = []
|
||||
|
||||
# Process in reverse order (most recent first)
|
||||
for i, line in enumerate(reversed(lines)):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Skip history metadata lines (timestamps starting with #)
|
||||
if line.startswith('#') and line[1:].isdigit():
|
||||
continue
|
||||
|
||||
cmd_hash = hashlib.sha256(line.encode()).hexdigest()[:16]
|
||||
|
||||
# Deduplicate
|
||||
if cmd_hash in seen_hashes:
|
||||
continue
|
||||
seen_hashes.add(cmd_hash)
|
||||
|
||||
commands.append({
|
||||
"command": line,
|
||||
"command_hash": cmd_hash,
|
||||
"line_number": len(lines) - i,
|
||||
"last_used": datetime.now(timezone.utc).isoformat(),
|
||||
"execution_count": 1, # We don't have real count from bash_history
|
||||
})
|
||||
|
||||
if len(commands) >= limit:
|
||||
break
|
||||
|
||||
logger.info(f"{LOG_PREFIX} Fetched {len(commands)} commands from {host_ip}")
|
||||
return commands
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
raise ShellHistoryError(f"Timeout reading history from {host_ip}")
|
||||
except Exception as e:
|
||||
raise ShellHistoryError(f"Error reading history from {host_ip}: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
async def fetch_zsh_history(
|
||||
self,
|
||||
host_ip: str,
|
||||
limit: int = 100,
|
||||
user: str = "automation"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch zsh history from a remote host.
|
||||
Similar to bash but reads .zsh_history.
|
||||
"""
|
||||
conn = None
|
||||
try:
|
||||
cmd = f"tail -n {limit * 2} ~{user}/.zsh_history 2>/dev/null || tail -n {limit * 2} ~/.zsh_history 2>/dev/null || echo ''"
|
||||
|
||||
if asyncssh is None:
|
||||
output = ""
|
||||
tried_users: List[str] = []
|
||||
last_err: Optional[BaseException] = None
|
||||
for login_user in [self.ssh_remote_user, self.ssh_user]:
|
||||
if not login_user or login_user in tried_users:
|
||||
continue
|
||||
tried_users.append(login_user)
|
||||
try:
|
||||
output = await self._run_ssh_subprocess(host_ip, login_user, cmd)
|
||||
last_err = None
|
||||
break
|
||||
except ShellHistoryError as e:
|
||||
last_err = e
|
||||
continue
|
||||
if last_err is not None and not output:
|
||||
raise last_err
|
||||
else:
|
||||
conn = await self._ssh_connect(host_ip)
|
||||
result = await asyncio.wait_for(
|
||||
conn.run(cmd, check=False),
|
||||
timeout=self.exec_timeout
|
||||
)
|
||||
output = result.stdout or ""
|
||||
|
||||
lines = output.strip().split('\n')
|
||||
|
||||
seen_hashes = set()
|
||||
commands = []
|
||||
|
||||
for i, line in enumerate(reversed(lines)):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
# Zsh extended history format: : timestamp:0;command
|
||||
if line.startswith(': ') and ';' in line:
|
||||
line = line.split(';', 1)[1]
|
||||
|
||||
cmd_hash = hashlib.sha256(line.encode()).hexdigest()[:16]
|
||||
|
||||
if cmd_hash in seen_hashes:
|
||||
continue
|
||||
seen_hashes.add(cmd_hash)
|
||||
|
||||
commands.append({
|
||||
"command": line,
|
||||
"command_hash": cmd_hash,
|
||||
"line_number": len(lines) - i,
|
||||
"last_used": datetime.now(timezone.utc).isoformat(),
|
||||
"execution_count": 1,
|
||||
})
|
||||
|
||||
if len(commands) >= limit:
|
||||
break
|
||||
|
||||
return commands
|
||||
|
||||
except Exception as e:
|
||||
raise ShellHistoryError(f"Error reading zsh history from {host_ip}: {e}")
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
async def fetch_combined_history(
|
||||
self,
|
||||
host_ip: str,
|
||||
limit: int = 100,
|
||||
user: str = "automation"
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Fetch history from both bash and zsh, combined and deduplicated.
|
||||
"""
|
||||
bash_commands = []
|
||||
zsh_commands = []
|
||||
|
||||
try:
|
||||
bash_commands = await self.fetch_bash_history(host_ip, limit, user)
|
||||
except ShellHistoryError:
|
||||
pass # Bash history not available
|
||||
|
||||
try:
|
||||
zsh_commands = await self.fetch_zsh_history(host_ip, limit, user)
|
||||
except ShellHistoryError:
|
||||
pass # Zsh history not available
|
||||
|
||||
# Combine and deduplicate
|
||||
seen_hashes = set()
|
||||
combined = []
|
||||
|
||||
for cmd in bash_commands + zsh_commands:
|
||||
if cmd["command_hash"] not in seen_hashes:
|
||||
seen_hashes.add(cmd["command_hash"])
|
||||
combined.append(cmd)
|
||||
if len(combined) >= limit:
|
||||
break
|
||||
|
||||
return combined
|
||||
|
||||
|
||||
# Singleton instance
|
||||
shell_history_service = ShellHistoryService()
|
||||
@@ -360,7 +360,7 @@ class TerminalService:
|
||||
"-o",
|
||||
"ServerAliveCountMax=2",
|
||||
"-o",
|
||||
"StrictHostKeyChecking=accept-new",
|
||||
"StrictHostKeyChecking=no",
|
||||
"-o",
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
f"{SSH_USER}@{host_ip}",
|
||||
@@ -382,8 +382,11 @@ class TerminalService:
|
||||
f"--port={port}",
|
||||
"--writable",
|
||||
]
|
||||
if ttyd_interface:
|
||||
cmd.extend(["--interface", ttyd_interface])
|
||||
# if ttyd_interface:
|
||||
# cmd.extend(["--interface", ttyd_interface])
|
||||
# Always bind to 0.0.0.0 (default) to ensure local proxy can connect via 127.0.0.1
|
||||
# regardless of which interface is primary (eth0, etc).
|
||||
|
||||
cmd.extend(ssh_cmd)
|
||||
|
||||
logger.info(
|
||||
|
||||
Reference in New Issue
Block a user