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

This commit is contained in:
2025-12-21 12:31:08 -05:00
parent 5ff7017aaa
commit 6d8432169b
137 changed files with 30407 additions and 651 deletions
+19
View File
@@ -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,
+349
View File
@@ -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()
+6 -3
View File
@@ -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(