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
+135 -13
View File
@@ -3169,10 +3169,18 @@
.terminal-history-panel {
background: #1e1e2e;
border-bottom: 1px solid #374151;
max-height: 300px;
max-height: 350px;
overflow: hidden;
display: flex;
flex-direction: column;
opacity: 0;
transform: translateY(-10px);
transition: opacity 0.2s ease, transform 0.2s ease;
}
.terminal-history-panel.open {
opacity: 1;
transform: translateY(0);
}
.terminal-history-header {
@@ -3180,12 +3188,14 @@
background: #1a1a2e;
display: flex;
align-items: center;
gap: 1rem;
gap: 0.75rem;
flex-shrink: 0;
flex-wrap: wrap;
}
.terminal-history-search {
flex: 1;
min-width: 180px;
display: flex;
align-items: center;
background: #0f0f1a;
@@ -3193,6 +3203,12 @@
border-radius: 0.5rem;
padding: 0 0.75rem;
gap: 0.5rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
.terminal-history-search:focus-within {
border-color: #7c3aed;
box-shadow: 0 0 0 2px rgba(124, 58, 237, 0.2);
}
.terminal-history-search i {
@@ -3222,37 +3238,81 @@
display: flex;
align-items: center;
justify-content: center;
transition: color 0.15s;
}
.terminal-history-clear-search:hover {
color: #9ca3af;
color: #ef4444;
}
.terminal-history-actions {
.terminal-history-filters {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.75rem;
flex-shrink: 0;
}
.terminal-history-filter-select {
background: #0f0f1a;
border: 1px solid #374151;
border-radius: 0.375rem;
color: #e5e7eb;
padding: 0.375rem 0.5rem;
font-size: 0.75rem;
cursor: pointer;
outline: none;
transition: border-color 0.15s;
}
.terminal-history-filter-select:hover,
.terminal-history-filter-select:focus {
border-color: #7c3aed;
}
.terminal-history-filter-select option {
background: #1a1a2e;
}
.terminal-history-scope {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 0.375rem;
font-size: 0.75rem;
color: #9ca3af;
cursor: pointer;
white-space: nowrap;
}
.terminal-history-scope input {
width: 1rem;
height: 1rem;
width: 0.875rem;
height: 0.875rem;
accent-color: #7c3aed;
cursor: pointer;
}
.terminal-history-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
scrollbar-width: thin;
scrollbar-color: #374151 transparent;
}
.terminal-history-list::-webkit-scrollbar {
width: 6px;
}
.terminal-history-list::-webkit-scrollbar-track {
background: transparent;
}
.terminal-history-list::-webkit-scrollbar-thumb {
background: #374151;
border-radius: 3px;
}
.terminal-history-list::-webkit-scrollbar-thumb:hover {
background: #4b5563;
}
.terminal-history-item {
@@ -3261,12 +3321,22 @@
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s;
transition: background 0.15s, transform 0.1s;
gap: 0.75rem;
border: 1px solid transparent;
}
.terminal-history-item:hover {
background: rgba(124, 58, 237, 0.15);
background: rgba(124, 58, 237, 0.1);
}
.terminal-history-item.selected {
background: rgba(124, 58, 237, 0.2);
border-color: rgba(124, 58, 237, 0.4);
}
.terminal-history-item:active {
transform: scale(0.99);
}
.terminal-history-cmd {
@@ -3275,7 +3345,7 @@
}
.terminal-history-cmd code {
font-family: 'JetBrains Mono', 'Fira Code', monospace;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.8125rem;
color: #a5b4fc;
white-space: nowrap;
@@ -3284,6 +3354,13 @@
display: block;
}
.terminal-history-cmd code mark {
background: rgba(251, 191, 36, 0.3);
color: #fbbf24;
padding: 0 0.125rem;
border-radius: 2px;
}
.terminal-history-meta {
display: flex;
align-items: center;
@@ -3294,6 +3371,7 @@
.terminal-history-time {
font-size: 0.6875rem;
color: #6b7280;
cursor: help;
}
.terminal-history-count {
@@ -3302,6 +3380,7 @@
background: rgba(124, 58, 237, 0.2);
padding: 0.125rem 0.375rem;
border-radius: 9999px;
font-weight: 500;
}
.terminal-history-host {
@@ -3319,7 +3398,8 @@
transition: opacity 0.15s;
}
.terminal-history-item:hover .terminal-history-actions-inline {
.terminal-history-item:hover .terminal-history-actions-inline,
.terminal-history-item.selected .terminal-history-actions-inline {
opacity: 1;
}
@@ -3334,11 +3414,18 @@
align-items: center;
justify-content: center;
font-size: 0.75rem;
transition: background 0.15s, color 0.15s, transform 0.1s;
}
.terminal-history-action:hover {
background: rgba(124, 58, 237, 0.3);
color: #fff;
transform: scale(1.1);
}
.terminal-history-action-execute:hover {
background: rgba(34, 197, 94, 0.3);
color: #4ade80;
}
.terminal-history-empty {
@@ -3349,10 +3436,18 @@
padding: 2rem;
color: #6b7280;
gap: 0.5rem;
text-align: center;
}
.terminal-history-empty i {
font-size: 1.5rem;
font-size: 2rem;
opacity: 0.5;
}
.terminal-history-empty-hint {
font-size: 0.75rem;
color: #4b5563;
margin-top: 0.25rem;
}
.terminal-history-loading {
@@ -3364,6 +3459,33 @@
gap: 0.5rem;
}
.terminal-history-footer {
padding: 0.5rem 1rem;
background: #151520;
border-top: 1px solid #374151;
flex-shrink: 0;
}
.terminal-history-hint {
font-size: 0.6875rem;
color: #6b7280;
display: flex;
align-items: center;
gap: 0.25rem;
flex-wrap: wrap;
}
.terminal-history-hint kbd {
background: #374151;
color: #e5e7eb;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
font-family: inherit;
font-size: 0.625rem;
border: 1px solid #4b5563;
box-shadow: 0 1px 0 #4b5563;
}
#terminalHistoryBtn.active {
background: #7c3aed;
color: #fff;
+683 -76
View File
@@ -95,6 +95,13 @@ class DashboardManager {
this.terminalHeartbeatInterval = null;
this.terminalHeartbeatIntervalMs = 15000; // 15 seconds
this.terminalOpeningPromise = null; // Prevent double-click
// Terminal History Enhanced State
this.terminalHistoryPanelOpen = false;
this.terminalHistorySelectedIndex = -1;
this.terminalHistoryTimeFilter = 'all'; // all, today, week, month
this.terminalHistoryStatusFilter = 'all'; // all, success, error
this._terminalHistoryKeyHandler = null;
// Configuration: collecte métriques
this.metricsCollectionInterval = 'off';
@@ -1551,7 +1558,7 @@ class DashboardManager {
// Get metrics for this host
const hostMetrics = this.hostMetrics[host.id] || null;
const metricsHtml = this.renderHostMetricsSection(hostMetrics);
const metricsHtml = this.renderHostMetricsSection(hostMetrics, host.id);
hostCard.innerHTML = `
<div class="flex items-start justify-between gap-3">
@@ -1658,7 +1665,7 @@ class DashboardManager {
}
// Render metrics section for a host card
renderHostMetricsSection(metrics) {
renderHostMetricsSection(metrics, hostId) {
if (!metrics || metrics.collection_status === 'unknown') {
return `
<div class="mt-3 p-2 bg-gray-800/50 rounded-lg border border-gray-700/50">
@@ -1709,14 +1716,14 @@ class DashboardManager {
? `${metrics.disk_root_used_gb?.toFixed(0) || 0}/${metrics.disk_root_total_gb?.toFixed(0) || 0} GB`
: '';
const hostId = metrics.host_id || '';
const effectiveHostId = hostId || metrics.host_id || '';
const diskDevices = Array.isArray(metrics.disk_devices) ? metrics.disk_devices : [];
const disks = diskDevices.filter(d => d && d.type === 'disk');
const diskCount = disks.length || 0;
const diskCountText = diskCount ? `${diskCount} disque${diskCount > 1 ? 's' : ''}` : '';
const diskInfo = Array.isArray(metrics.disk_info) ? metrics.disk_info : [];
const showDiskDetails = (diskCount > 0) || (diskInfo.length > 0);
const diskExpanded = hostId && this.expandedHostDiskDetails.has(hostId);
const diskExpanded = effectiveHostId && this.expandedHostDiskDetails.has(effectiveHostId);
const formatBytes = (bytes) => {
if (bytes === null || bytes === undefined || bytes === '') return '';
@@ -2194,16 +2201,340 @@ class DashboardManager {
</div>
${showDiskDetails ? `
<div class="mt-2 flex justify-end">
<button class="text-[11px] text-gray-400 hover:text-gray-200 transition-colors" onclick="dashboard.toggleHostDiskDetails('${this.escapeHtml(hostId)}')">
<button class="text-[11px] text-gray-400 hover:text-gray-200 transition-colors" onclick="dashboard.toggleHostDiskDetails('${this.escapeHtml(effectiveHostId)}')">
<i class="fas ${diskExpanded ? 'fa-chevron-up' : 'fa-chevron-down'} mr-1"></i>${diskExpanded ? 'Masquer' : 'Détails'}
</button>
</div>
${renderDiskDevices()}
` : ''}
${this.renderStorageDetailsSection(metrics, effectiveHostId)}
</div>
`;
}
// Render detailed storage section (accordion)
renderStorageDetailsSection(metrics, hostId) {
const storageDetails = metrics?.storage_details;
const isExpanded = this.expandedStorageDetails?.has(hostId) || false;
// Use storage_details if available, otherwise fallback to existing metrics fields
const hasStorageDetails = !!storageDetails;
const status = storageDetails?.status || 'unknown';
const osType = storageDetails?.os_type || metrics?.os_name || 'unknown';
const flags = storageDetails?.feature_flags || {};
const summary = storageDetails?.summary || {};
// Filesystems: from storage_details or build from disk_info
let filesystems = storageDetails?.filesystems || [];
if (!filesystems.length && metrics?.disk_info?.length) {
filesystems = metrics.disk_info.map(d => ({
device: d.device || '',
fstype: d.filesystem || d.fstype || '',
mountpoint: d.mount || d.mount_point || '',
size_bytes: d.total_gb ? d.total_gb * 1024 * 1024 * 1024 : 0,
used_bytes: d.total_gb && d.free_gb ? (d.total_gb - d.free_gb) * 1024 * 1024 * 1024 : 0,
avail_bytes: d.free_gb ? d.free_gb * 1024 * 1024 * 1024 : 0,
use_pct: d.usage_percent || 0
}));
}
// Block devices from storage_details or disk_devices
const blockDevices = storageDetails?.block_devices || metrics?.disk_devices || [];
// ZFS from storage_details or zfs_info
const zfsPools = storageDetails?.zfs?.pools || metrics?.zfs_info?.pools || [];
const zfsDatasets = storageDetails?.zfs?.datasets || metrics?.zfs_info?.datasets || [];
// LVM from storage_details or lvm_info
const lvmVgs = storageDetails?.lvm?.vgs || metrics?.lvm_info?.vgs || [];
const commandsRun = storageDetails?.commands_run || [];
const partialFailures = storageDetails?.partial_failures || [];
const collectedAt = storageDetails?.collected_at || '';
// Check if we have any data to show
const hasData = filesystems.length > 0 || blockDevices.length > 0 || zfsPools.length > 0 || lvmVgs.length > 0;
// Build summary chips
const chips = [];
if (filesystems.length) chips.push(`${filesystems.length} FS`);
if (blockDevices.length) chips.push(`${blockDevices.length} disques`);
if (zfsPools.length || flags.has_zfs) chips.push('ZFS');
if (lvmVgs.length || flags.has_lvm) chips.push('LVM');
// Calculate usage from summary or from filesystems
let usedPct = summary.used_pct ? Number(summary.used_pct).toFixed(0) : null;
let totalBytes = summary.total_bytes || 0;
let usedBytes = summary.used_bytes || 0;
// If no summary, calculate from filesystems (exclude virtual fs)
if (!totalBytes && filesystems.length) {
const realFs = filesystems.filter(fs => {
const mp = (fs.mountpoint || '').toLowerCase();
const dev = (fs.device || '').toLowerCase();
return !mp.startsWith('/run') && !mp.startsWith('/sys') && !mp.startsWith('/proc') &&
!dev.includes('tmpfs') && !dev.includes('devtmpfs');
});
totalBytes = realFs.reduce((sum, fs) => sum + (fs.size_bytes || 0), 0);
usedBytes = realFs.reduce((sum, fs) => sum + (fs.used_bytes || 0), 0);
if (totalBytes > 0) {
usedPct = ((usedBytes / totalBytes) * 100).toFixed(0);
}
}
const formatBytes = (bytes) => {
if (!bytes || bytes <= 0) return '';
const gb = bytes / (1024 * 1024 * 1024);
if (gb >= 1024) return `${(gb / 1024).toFixed(1)} TB`;
if (gb >= 100) return `${gb.toFixed(0)} GB`;
return `${gb.toFixed(1)} GB`;
};
const summaryLine = chips.length ? chips.join(' • ') : 'Aucune donnée';
const usageLine = usedPct !== null ? `${usedPct}% utilisé` : '';
const sizeLine = totalBytes > 0 ? `${formatBytes(usedBytes)} / ${formatBytes(totalBytes)}` : '';
const statusBadge = status === 'ok' ? 'bg-green-500/20 text-green-400' :
status === 'partial' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-red-500/20 text-red-400';
const statusText = status === 'ok' ? 'OK' : status === 'partial' ? 'Partiel' : 'Erreur';
const getPctColor = (pct) => {
if (pct === null || pct === undefined) return 'bg-gray-600';
return pct >= 90 ? 'bg-red-500' : pct >= 75 ? 'bg-yellow-500' : 'bg-green-500';
};
const renderFilesystemsTable = () => {
if (!filesystems.length) return '<div class="text-[11px] text-gray-500 italic">Aucun filesystem détecté</div>';
// Filter out virtual filesystems
const filtered = filesystems.filter(fs => {
const mp = (fs.mountpoint || '').toLowerCase();
const dev = (fs.device || '').toLowerCase();
return !mp.startsWith('/run') && !mp.startsWith('/sys') && !mp.startsWith('/proc') &&
!dev.includes('tmpfs') && !dev.includes('devtmpfs');
});
return `
<div class="overflow-x-auto">
<table class="w-full text-[11px]">
<thead>
<tr class="text-gray-500 border-b border-gray-700">
<th class="text-left py-1 px-2">Mount</th>
<th class="text-left py-1 px-2">Device</th>
<th class="text-left py-1 px-2">FS</th>
<th class="text-right py-1 px-2">Taille</th>
<th class="text-right py-1 px-2">Utilisé</th>
<th class="text-right py-1 px-2">%</th>
</tr>
</thead>
<tbody>
${filtered.slice(0, 20).map(fs => {
const pct = fs.use_pct !== undefined ? Number(fs.use_pct) : null;
const pctColor = pct >= 90 ? 'text-red-400' : pct >= 75 ? 'text-yellow-400' : 'text-green-400';
const rowClass = pct >= 85 ? 'bg-red-500/10' : '';
const rawDevice = (fs.device || '-');
const deviceDisplay = (typeof rawDevice === 'string' && rawDevice.startsWith('/dev/'))
? rawDevice.slice('/dev/'.length)
: rawDevice;
return `
<tr class="border-b border-gray-800 hover:bg-gray-800/50 ${rowClass}">
<td class="py-1 px-2 text-gray-300 truncate max-w-[150px]" title="${this.escapeHtml(fs.mountpoint || '')}">${this.escapeHtml(fs.mountpoint || '-')}</td>
<td class="py-1 px-2 text-gray-400 truncate max-w-[120px]" title="${this.escapeHtml(deviceDisplay || '')}">${this.escapeHtml(deviceDisplay || '-')}</td>
<td class="py-1 px-2 text-gray-500">${this.escapeHtml(fs.fstype || '-')}</td>
<td class="py-1 px-2 text-gray-400 text-right">${formatBytes(fs.size_bytes)}</td>
<td class="py-1 px-2 text-gray-400 text-right">${formatBytes(fs.used_bytes)}</td>
<td class="py-1 px-2 text-right font-medium ${pctColor}">${pct !== null ? pct + '%' : '-'}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
};
const renderZfsSection = () => {
if (!zfsPools.length && !zfsDatasets.length) return '';
const poolCards = zfsPools.map(pool => {
const pct = pool.cap_pct !== undefined ? Number(pool.cap_pct) : null;
const color = getPctColor(pct);
const healthColor = pool.health === 'ONLINE' ? 'text-green-400' :
pool.health === 'DEGRADED' ? 'text-yellow-400' : 'text-red-400';
return `
<div class="p-2 rounded bg-gray-900/50 border border-gray-700/50">
<div class="flex items-center justify-between">
<span class="text-[11px] text-gray-200 font-medium"><i class="fas fa-water mr-1 text-blue-400"></i>${this.escapeHtml(pool.name || 'pool')}</span>
<span class="text-[10px] ${healthColor}">${pool.health || 'UNKNOWN'}</span>
</div>
<div class="mt-1 h-1.5 bg-gray-700 rounded-full overflow-hidden">
<div class="h-full ${color}" style="width:${pct || 0}%"></div>
</div>
<div class="mt-1 flex justify-between text-[10px] text-gray-500">
<span>${formatBytes(pool.alloc_bytes)} / ${formatBytes(pool.size_bytes)}</span>
<span>${pct !== null ? pct + '%' : ''}</span>
</div>
</div>
`;
}).join('');
const datasetsList = zfsDatasets.slice(0, 15).map(ds => `
<div class="flex items-center justify-between py-1 border-b border-gray-800">
<span class="text-[10px] text-gray-300 truncate max-w-[200px]" title="${this.escapeHtml(ds.name || '')}">${this.escapeHtml(ds.name || '')}</span>
<span class="text-[10px] text-gray-500">${formatBytes(ds.used_bytes)} used</span>
</div>
`).join('');
return `
<div class="mt-3">
<div class="flex items-center gap-2 mb-2">
<span class="text-[11px] text-blue-400 font-medium"><i class="fas fa-water mr-1"></i>ZFS</span>
<span class="text-[10px] text-gray-500">${zfsPools.length} pool(s) • ${zfsDatasets.length} dataset(s)</span>
</div>
${poolCards ? `<div class="grid grid-cols-1 sm:grid-cols-2 gap-2 mb-2">${poolCards}</div>` : ''}
${zfsDatasets.length > 0 ? `<div class="max-h-[200px] overflow-y-auto">${datasetsList}</div>` : ''}
</div>
`;
};
const renderLvmSection = () => {
if (!lvmVgs.length) return '';
const vgCards = lvmVgs.map(vg => {
const name = vg.vg_name || vg.name || 'VG';
const size = vg.vg_size || vg.size || '';
const free = vg.vg_free || vg.free || '';
return `
<div class="p-2 rounded bg-gray-900/50 border border-gray-700/50">
<div class="text-[11px] text-gray-200 font-medium"><i class="fas fa-layer-group mr-1 text-purple-400"></i>${this.escapeHtml(name)}</div>
<div class="text-[10px] text-gray-500">${size}${free ? ` • ${free} free` : ''}</div>
</div>
`;
}).join('');
return `
<div class="mt-3">
<div class="flex items-center gap-2 mb-2">
<span class="text-[11px] text-purple-400 font-medium"><i class="fas fa-layer-group mr-1"></i>LVM</span>
<span class="text-[10px] text-gray-500">${lvmVgs.length} VG(s)</span>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2">${vgCards}</div>
</div>
`;
};
const renderInspectorDrawer = () => {
if (!this.storageInspectorOpen?.has(hostId)) return '';
const cmdList = commandsRun.map(cmd => `
<div class="flex items-center justify-between py-1 border-b border-gray-800">
<span class="text-[10px] text-gray-300"><i class="fas fa-terminal mr-1 text-gray-500"></i>${this.escapeHtml(cmd.cmd || '')}</span>
<span class="text-[10px] ${cmd.status === 'ok' ? 'text-green-400' : 'text-red-400'}">${cmd.status || ''}</span>
</div>
`).join('') || '<div class="text-[10px] text-gray-500 italic">Aucune commande</div>';
const failuresList = partialFailures.length ? partialFailures.map(f => `
<div class="text-[10px] text-yellow-400"><i class="fas fa-exclamation-triangle mr-1"></i>${this.escapeHtml(f)}</div>
`).join('') : '';
return `
<div class="mt-2 p-3 bg-gray-950 rounded-lg border border-gray-700">
<div class="flex items-center justify-between mb-2">
<span class="text-[11px] text-gray-400 font-medium"><i class="fas fa-info-circle mr-1"></i>Inspecteur</span>
<button onclick="dashboard.toggleStorageInspector('${this.escapeHtml(hostId)}')" class="text-gray-500 hover:text-gray-300">
<i class="fas fa-times"></i>
</button>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-[10px] text-gray-500">OS détecté</span>
<span class="text-[10px] text-gray-300">${this.escapeHtml(osType)}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-[10px] text-gray-500">Collecté le</span>
<span class="text-[10px] text-gray-300">${collectedAt ? new Date(collectedAt).toLocaleString('fr-FR') : '-'}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-[10px] text-gray-500">Statut</span>
<span class="px-1.5 py-0.5 rounded text-[9px] ${statusBadge}">${statusText}</span>
</div>
<div class="mt-2">
<div class="text-[10px] text-gray-500 mb-1">Commandes exécutées</div>
${cmdList}
</div>
${failuresList ? `
<div class="mt-2">
<div class="text-[10px] text-gray-500 mb-1">Avertissements</div>
${failuresList}
</div>
` : ''}
</div>
</div>
`;
};
// Don't show section if no data at all
if (!hasData) {
return '';
}
// Status badge - show "Données existantes" if using fallback data
const displayStatusBadge = hasStorageDetails ? statusBadge : 'bg-blue-500/20 text-blue-400';
const displayStatusText = hasStorageDetails ? statusText : 'Données';
return `
<div class="mt-3 border-t border-gray-700/50 pt-3">
<div class="flex items-center justify-between cursor-pointer" onclick="dashboard.toggleStorageDetails('${this.escapeHtml(hostId)}')">
<div class="flex items-center gap-2">
<i class="fas fa-database text-indigo-400"></i>
<span class="text-xs text-gray-400 font-medium">Stockage détaillé</span>
<span class="px-1.5 py-0.5 rounded text-[9px] ${displayStatusBadge}">${displayStatusText}</span>
${chips.map(c => `<span class="px-1.5 py-0.5 rounded bg-gray-700/50 text-[9px] text-gray-400">${c}</span>`).join('')}
</div>
<div class="flex items-center gap-2">
<span class="text-[10px] text-gray-500">${usageLine}${sizeLine ? ` • ${sizeLine}` : ''}</span>
${hasStorageDetails ? `
<button onclick="event.stopPropagation(); dashboard.toggleStorageInspector('${this.escapeHtml(hostId)}')" class="text-gray-500 hover:text-indigo-400 transition-colors" title="Mode inspecteur">
<i class="fas fa-info-circle"></i>
</button>
` : ''}
<i class="fas ${isExpanded ? 'fa-chevron-up' : 'fa-chevron-down'} text-gray-500"></i>
</div>
</div>
${isExpanded ? `
<div class="mt-3">
${renderFilesystemsTable()}
${renderZfsSection()}
${renderLvmSection()}
</div>
` : ''}
${hasStorageDetails ? renderInspectorDrawer() : ''}
</div>
`;
}
// Toggle storage details accordion
toggleStorageDetails(hostId) {
if (!this.expandedStorageDetails) this.expandedStorageDetails = new Set();
if (this.expandedStorageDetails.has(hostId)) {
this.expandedStorageDetails.delete(hostId);
} else {
this.expandedStorageDetails.add(hostId);
}
this.renderHosts();
}
// Toggle storage inspector drawer
toggleStorageInspector(hostId) {
if (!this.storageInspectorOpen) this.storageInspectorOpen = new Set();
if (this.storageInspectorOpen.has(hostId)) {
this.storageInspectorOpen.delete(hostId);
} else {
this.storageInspectorOpen.add(hostId);
}
this.renderHosts();
}
// Collect metrics for all hosts
async collectAllHostMetrics() {
if (this.metricsLoading) {
@@ -5129,11 +5460,13 @@ class DashboardManager {
copyTaskLogOutput() {
const text = this.currentTaskLogRawOutput || '';
navigator.clipboard.writeText(text).then(() => {
this.showNotification('Sortie copiée dans le presse-papiers', 'success');
}).catch(() => {
this.showNotification('Erreur lors de la copie', 'error');
});
this.copyTextToClipboard(text)
.then(() => {
this.showNotification('Sortie copiée dans le presse-papiers', 'success');
})
.catch(() => {
this.showNotification('Erreur lors de la copie', 'error');
});
}
downloadTaskLog(path) {
@@ -5609,11 +5942,41 @@ class DashboardManager {
}
copyToClipboard(text) {
navigator.clipboard.writeText(text).then(() => {
this.showNotification('Copié dans le presse-papiers', 'success');
}).catch(() => {
this.showNotification('Erreur lors de la copie', 'error');
});
this.copyTextToClipboard(text)
.then(() => {
this.showNotification('Copié dans le presse-papiers', 'success');
})
.catch(() => {
this.showNotification('Erreur lors de la copie', 'error');
});
}
async copyTextToClipboard(text) {
const value = String(text ?? '');
if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
try {
await navigator.clipboard.writeText(value);
return;
} catch (e) {
// fall back
}
}
const ta = document.createElement('textarea');
ta.value = value;
ta.setAttribute('readonly', '');
ta.style.position = 'fixed';
ta.style.left = '-9999px';
ta.style.top = '0';
document.body.appendChild(ta);
ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
if (!ok) {
throw new Error('copy_failed');
}
}
async clearCompletedTasks() {
@@ -10265,17 +10628,24 @@ class DashboardManager {
<i class="fas fa-search"></i>
<input type="text"
id="terminalHistorySearch"
placeholder="Rechercher une commande..."
placeholder="Rechercher... (Ctrl+R)"
oninput="dashboard.searchTerminalHistory(this.value)"
onkeydown="dashboard.handleHistorySearchKeydown(event)"
autocomplete="off">
<button class="terminal-history-clear-search" onclick="dashboard.clearTerminalHistorySearch()" title="Effacer">
<i class="fas fa-times"></i>
</button>
</div>
<div class="terminal-history-actions">
<div class="terminal-history-filters">
<select id="terminalHistoryTimeFilter" onchange="dashboard.setHistoryTimeFilter(this.value)" class="terminal-history-filter-select">
<option value="all">Tout</option>
<option value="today">Aujourd'hui</option>
<option value="week">7 jours</option>
<option value="month">30 jours</option>
</select>
<label class="terminal-history-scope">
<input type="checkbox" id="terminalHistoryAllHosts" onchange="dashboard.toggleHistoryScope()">
<span>Tous les hôtes</span>
<span>Tous hôtes</span>
</label>
</div>
</div>
@@ -10284,6 +10654,11 @@ class DashboardManager {
<i class="fas fa-spinner fa-spin"></i> Chargement...
</div>
</div>
<div class="terminal-history-footer">
<span class="terminal-history-hint">
<kbd>↑</kbd><kbd>↓</kbd> naviguer · <kbd>Enter</kbd> insérer · <kbd>Esc</kbd> fermer
</span>
</div>
</div>
<div class="terminal-drawer-body" id="terminalDrawerBody">
@@ -10306,11 +10681,22 @@ class DashboardManager {
drawer.classList.add('open');
});
// Add escape key handler
// Add keyboard handlers (Escape to close, Ctrl+R for history)
this._terminalEscHandler = (e) => {
if (e.key === 'Escape' && this.terminalDrawerOpen) {
// If history panel is open, close it first
if (this.terminalHistoryPanelOpen) {
this.closeTerminalHistoryPanel();
e.preventDefault();
return;
}
this.closeTerminalDrawer();
}
// Ctrl+R to open/focus history search
if ((e.ctrlKey || e.metaKey) && e.key === 'r' && this.terminalDrawerOpen) {
e.preventDefault();
this.openTerminalHistoryPanel();
}
};
document.addEventListener('keydown', this._terminalEscHandler);
}
@@ -10496,7 +10882,7 @@ class DashboardManager {
}
try {
await this.apiCall(`/api/terminal/sessions/${this.terminalSession.session_id}/heartbeat`, {
await this.apiCall(`/api/terminal/sessions/${this.terminalSession.session_id}/heartbeat?token=${encodeURIComponent(this.terminalSession.token)}`, {
method: 'POST'
});
} catch (e) {
@@ -10674,11 +11060,13 @@ class DashboardManager {
}
const cmd = `ssh automation@${this.terminalSession.host.ip}`;
navigator.clipboard.writeText(cmd).then(() => {
this.showNotification(`Commande copiée: ${cmd}`, 'success');
}).catch(() => {
this.showNotification('Impossible de copier dans le presse-papier', 'error');
});
this.copyTextToClipboard(cmd)
.then(() => {
this.showNotification(`Commande copiée: ${cmd}`, 'success');
})
.catch(() => {
this.showNotification('Impossible de copier dans le presse-papier', 'error');
});
}
async reconnectTerminal() {
@@ -10697,27 +11085,78 @@ class DashboardManager {
}
// ===== TERMINAL COMMAND HISTORY =====
async logTerminalCommand(command) {
try {
if (!this.terminalSession) return;
const sessionId = this.terminalSession.session_id;
const token = this.terminalSession.token;
const cmd = String(command ?? '').trim();
if (!cmd) return;
const endpoint = `/api/terminal/sessions/${encodeURIComponent(sessionId)}/command?token=${encodeURIComponent(token)}`;
await this.apiCall(endpoint, {
method: 'POST',
body: JSON.stringify({ command: cmd })
});
} catch (e) {
// Best-effort: do not disrupt UX if logging fails
}
}
async toggleTerminalHistory() {
if (this.terminalHistoryPanelOpen) {
this.closeTerminalHistoryPanel();
} else {
this.openTerminalHistoryPanel();
}
}
async openTerminalHistoryPanel() {
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('terminalHistoryBtn');
if (!panel) return;
const isVisible = panel.style.display !== 'none';
this.terminalHistoryPanelOpen = true;
this.terminalHistorySelectedIndex = -1;
if (isVisible) {
panel.style.display = 'flex';
panel.classList.add('open');
btn?.classList.add('active');
// Load history if not loaded
if (this.terminalCommandHistory.length === 0) {
await this.loadTerminalCommandHistory();
}
// Focus search input
const searchInput = document.getElementById('terminalHistorySearch');
if (searchInput) {
searchInput.focus();
searchInput.select();
}
}
closeTerminalHistoryPanel() {
const panel = document.getElementById('terminalHistoryPanel');
const btn = document.getElementById('terminalHistoryBtn');
if (!panel) return;
this.terminalHistoryPanelOpen = false;
this.terminalHistorySelectedIndex = -1;
panel.classList.remove('open');
setTimeout(() => {
panel.style.display = 'none';
btn?.classList.remove('active');
} else {
panel.style.display = 'block';
btn?.classList.add('active');
// Load history if not loaded
if (this.terminalCommandHistory.length === 0) {
await this.loadTerminalCommandHistory();
}
// Focus search input
document.getElementById('terminalHistorySearch')?.focus();
}, 200);
btn?.classList.remove('active');
// Return focus to terminal
const iframe = document.getElementById('terminalIframe');
if (iframe) {
iframe.focus();
}
}
@@ -10733,16 +11172,69 @@ class DashboardManager {
try {
const allHosts = document.getElementById('terminalHistoryAllHosts')?.checked || false;
const hostId = this.terminalSession.host.id;
const timeFilter = this.terminalHistoryTimeFilter;
// Build query params
const params = new URLSearchParams();
params.set('limit', '100');
if (query) params.set('query', query);
// Add time filter
if (timeFilter !== 'all') {
const now = new Date();
let since;
switch (timeFilter) {
case 'today':
since = new Date(now.getFullYear(), now.getMonth(), now.getDate());
break;
case 'week':
since = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case 'month':
since = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
}
if (since) {
params.set('since', since.toISOString());
}
}
let endpoint;
if (allHosts) {
endpoint = `/api/terminal/command-history?limit=50${query ? `&query=${encodeURIComponent(query)}` : ''}`;
endpoint = `/api/terminal/command-history?${params.toString()}`;
} else {
endpoint = `/api/terminal/${hostId}/command-history/unique?limit=50${query ? `&query=${encodeURIComponent(query)}` : ''}`;
// Use shell-history to fetch real commands from the remote host via SSH
endpoint = `/api/terminal/${hostId}/shell-history?${params.toString()}`;
}
const response = await this.apiCall(endpoint);
this.terminalCommandHistory = response.commands || [];
let commands = response.commands || [];
// Client-side time filtering if API doesn't support it
if (timeFilter !== 'all') {
const now = new Date();
let cutoff;
switch (timeFilter) {
case 'today':
cutoff = new Date(now.getFullYear(), now.getMonth(), now.getDate());
break;
case 'week':
cutoff = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000);
break;
case 'month':
cutoff = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
break;
}
if (cutoff) {
commands = commands.filter(cmd => {
const cmdDate = new Date(cmd.last_used || cmd.created_at);
return cmdDate >= cutoff;
});
}
}
this.terminalCommandHistory = commands;
this.terminalHistorySelectedIndex = -1;
this.renderTerminalHistory();
@@ -10754,15 +11246,21 @@ class DashboardManager {
}
}
renderTerminalHistory() {
renderTerminalHistory(highlightQuery = '') {
const listEl = document.getElementById('terminalHistoryList');
if (!listEl) return;
const query = highlightQuery || this.terminalHistorySearchQuery || '';
if (this.terminalCommandHistory.length === 0) {
const emptyMessage = query
? `Aucun résultat pour "${this.escapeHtml(query)}"`
: 'Aucune commande dans l\'historique';
listEl.innerHTML = `
<div class="terminal-history-empty">
<i class="fas fa-terminal"></i>
<span>Aucune commande dans l'historique</span>
<span>${emptyMessage}</span>
${query ? '<span class="terminal-history-empty-hint">Essayez une recherche différente</span>' : ''}
</div>
`;
return;
@@ -10773,23 +11271,35 @@ class DashboardManager {
const timeAgo = this.formatRelativeTime(cmd.last_used || cmd.created_at);
const execCount = cmd.execution_count || 1;
const hostName = cmd.host_name || '';
const isSelected = index === this.terminalHistorySelectedIndex;
// Highlight search query in command
let displayCommand = this.escapeHtml(command.length > 80 ? command.substring(0, 80) + '...' : command);
if (query) {
const regex = new RegExp(`(${this.escapeRegExp(query)})`, 'gi');
displayCommand = displayCommand.replace(regex, '<mark>$1</mark>');
}
return `
<div class="terminal-history-item" onclick="dashboard.insertTerminalCommand(${index})" title="${this.escapeHtml(command)}">
<div class="terminal-history-item${isSelected ? ' selected' : ''}"
data-index="${index}"
onclick="dashboard.selectAndInsertHistoryCommand(${index})"
ondblclick="dashboard.executeHistoryCommand(${index})"
title="${this.escapeHtml(command)}">
<div class="terminal-history-cmd">
<code>${this.escapeHtml(command.length > 60 ? command.substring(0, 60) + '...' : command)}</code>
<code>${displayCommand}</code>
</div>
<div class="terminal-history-meta">
<span class="terminal-history-time">${timeAgo}</span>
${execCount > 1 ? `<span class="terminal-history-count">×${execCount}</span>` : ''}
<span class="terminal-history-time" title="${new Date(cmd.last_used || cmd.created_at).toLocaleString('fr-FR')}">${timeAgo}</span>
${execCount > 1 ? `<span class="terminal-history-count" title="${execCount} exécutions">×${execCount}</span>` : ''}
${hostName ? `<span class="terminal-history-host">${this.escapeHtml(hostName)}</span>` : ''}
</div>
<div class="terminal-history-actions-inline">
<button class="terminal-history-action" onclick="event.stopPropagation(); dashboard.copyTerminalCommand(${index})" title="Copier">
<i class="fas fa-copy"></i>
</button>
<button class="terminal-history-action" onclick="event.stopPropagation(); dashboard.insertTerminalCommand(${index})" title="Insérer">
<i class="fas fa-arrow-right"></i>
<button class="terminal-history-action terminal-history-action-execute" onclick="event.stopPropagation(); dashboard.executeHistoryCommand(${index})" title="Exécuter">
<i class="fas fa-play"></i>
</button>
</div>
</div>
@@ -10797,10 +11307,77 @@ class DashboardManager {
}).join('');
listEl.innerHTML = items;
// Scroll selected item into view
if (this.terminalHistorySelectedIndex >= 0) {
const selectedEl = listEl.querySelector('.terminal-history-item.selected');
if (selectedEl) {
selectedEl.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
}
}
}
escapeRegExp(string) {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
handleHistorySearchKeydown(event) {
const key = event.key;
const historyLength = this.terminalCommandHistory.length;
switch (key) {
case 'ArrowDown':
event.preventDefault();
if (historyLength > 0) {
this.terminalHistorySelectedIndex = Math.min(
this.terminalHistorySelectedIndex + 1,
historyLength - 1
);
this.renderTerminalHistory();
}
break;
case 'ArrowUp':
event.preventDefault();
if (historyLength > 0) {
this.terminalHistorySelectedIndex = Math.max(
this.terminalHistorySelectedIndex - 1,
0
);
this.renderTerminalHistory();
}
break;
case 'Enter':
event.preventDefault();
if (this.terminalHistorySelectedIndex >= 0) {
this.selectAndInsertHistoryCommand(this.terminalHistorySelectedIndex);
} else if (historyLength > 0) {
this.selectAndInsertHistoryCommand(0);
}
break;
case 'Escape':
event.preventDefault();
this.closeTerminalHistoryPanel();
break;
case 'Tab':
// Tab to cycle through results
event.preventDefault();
if (event.shiftKey) {
this.terminalHistorySelectedIndex = Math.max(this.terminalHistorySelectedIndex - 1, 0);
} else {
this.terminalHistorySelectedIndex = Math.min(this.terminalHistorySelectedIndex + 1, historyLength - 1);
}
this.renderTerminalHistory();
break;
}
}
searchTerminalHistory(query) {
this.terminalHistorySearchQuery = query;
this.terminalHistorySelectedIndex = -1;
// Debounce search
if (this._historySearchTimeout) {
@@ -10809,54 +11386,81 @@ class DashboardManager {
this._historySearchTimeout = setTimeout(() => {
this.loadTerminalCommandHistory(query);
}, 300);
}, 250);
}
clearTerminalHistorySearch() {
const input = document.getElementById('terminalHistorySearch');
if (input) {
input.value = '';
input.focus();
}
this.terminalHistorySearchQuery = '';
this.terminalHistorySelectedIndex = -1;
this.loadTerminalCommandHistory('');
}
toggleHistoryScope() {
// Reload with current search query
setHistoryTimeFilter(value) {
this.terminalHistoryTimeFilter = value;
this.terminalHistorySelectedIndex = -1;
this.loadTerminalCommandHistory(this.terminalHistorySearchQuery);
}
insertTerminalCommand(index) {
toggleHistoryScope() {
this.terminalHistorySelectedIndex = -1;
this.loadTerminalCommandHistory(this.terminalHistorySearchQuery);
}
selectAndInsertHistoryCommand(index) {
const cmd = this.terminalCommandHistory[index];
if (!cmd) return;
const command = cmd.command || '';
// Try to paste into the terminal iframe
const iframe = document.getElementById('terminalIframe');
if (iframe && iframe.contentWindow) {
try {
// Write to clipboard and show notification
navigator.clipboard.writeText(command).then(() => {
this.showNotification('Commande copiée - Collez avec Ctrl+Shift+V dans le terminal', 'success');
// Focus the iframe
iframe.focus();
iframe.contentWindow.focus();
}).catch(() => {
this.showNotification('Commande: ' + command, 'info');
});
} catch (e) {
this.showNotification('Commande: ' + command, 'info');
// Copy to clipboard and show notification
this.copyTextToClipboard(command).then(() => {
this.showNotification('Commande copiée - Collez avec Ctrl+Shift+V', 'success');
// 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();
}
} else {
navigator.clipboard.writeText(command).then(() => {
this.showNotification('Commande copiée dans le presse-papier', 'success');
});
}
}).catch(() => {
this.showNotification('Commande: ' + command, 'info');
});
}
executeHistoryCommand(index) {
const cmd = this.terminalCommandHistory[index];
if (!cmd) return;
// Close history panel
this.toggleTerminalHistory();
const command = cmd.command || '';
// Copy command + newline to execute it
this.copyTextToClipboard(command + '\n').then(() => {
this.showNotification('Commande copiée avec Enter - Collez pour exécuter', 'success');
// Best-effort log
this.logTerminalCommand(command);
this.closeTerminalHistoryPanel();
const iframe = document.getElementById('terminalIframe');
if (iframe && iframe.contentWindow) {
iframe.focus();
iframe.contentWindow.focus();
}
}).catch(() => {
this.showNotification('Commande: ' + command, 'info');
});
}
copyTerminalCommand(index) {
@@ -10865,8 +11469,11 @@ class DashboardManager {
const command = cmd.command || '';
navigator.clipboard.writeText(command).then(() => {
this.copyTextToClipboard(command).then(() => {
this.showNotification('Commande copiée', 'success');
// Best-effort log
this.logTerminalCommand(command);
}).catch(() => {
this.showNotification('Impossible de copier', 'error');
});
+3
View File
@@ -59,6 +59,9 @@ class HostMetrics(Base):
lvm_info: Mapped[Optional[object]] = mapped_column(JSON)
zfs_info: Mapped[Optional[object]] = mapped_column(JSON)
# Stockage détaillé (JSON) - données normalisées avec métadonnées de collecte
storage_details: Mapped[Optional[dict]] = mapped_column(JSON)
# Informations système
os_name: Mapped[Optional[str]] = mapped_column(String(100))
os_version: Mapped[Optional[str]] = mapped_column(String(100))
+9
View File
@@ -61,12 +61,21 @@ async def get_all_hosts_metrics(
"cpu_load_1m": m.cpu_load_1m,
"cpu_model": m.cpu_model,
"cpu_count": m.cpu_count,
"cpu_cores": getattr(m, "cpu_cores", None),
"cpu_threads": getattr(m, "cpu_threads", None),
"cpu_max_mhz": getattr(m, "cpu_max_mhz", None),
"cpu_temperature": getattr(m, "cpu_temperature", None),
"memory_usage_percent": m.memory_usage_percent,
"memory_total_mb": m.memory_total_mb,
"memory_used_mb": m.memory_used_mb,
"disk_root_usage_percent": m.disk_root_usage_percent,
"disk_root_total_gb": m.disk_root_total_gb,
"disk_root_used_gb": m.disk_root_used_gb,
"disk_info": getattr(m, "disk_info", None),
"disk_devices": getattr(m, "disk_devices", None),
"lvm_info": getattr(m, "lvm_info", None),
"zfs_info": getattr(m, "zfs_info", None),
"storage_details": getattr(m, "storage_details", None),
"os_name": m.os_name,
"uptime_human": m.uptime_human,
"last_collected": collected_at,
+1620 -557
View File
File diff suppressed because it is too large Load Diff
+116 -1
View File
@@ -2,9 +2,17 @@
Routes WebSocket pour les mises à jour en temps réel.
"""
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
import asyncio
import logging
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, HTTPException, status
from app.services import ws_manager
from app.crud.terminal_session import TerminalSessionRepository
from app.services.terminal_service import terminal_service
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -20,3 +28,110 @@ async def websocket_endpoint(websocket: WebSocket):
# Traiter les messages entrants si nécessaire
except WebSocketDisconnect:
ws_manager.disconnect(websocket)
@router.websocket("/terminal/ws/{session_id}")
async def terminal_websocket_proxy(
websocket: WebSocket,
session_id: str,
):
"""
WebSocket proxy for ttyd terminal sessions.
This endpoint proxies WebSocket connections to the local ttyd instance,
allowing the browser to connect through the exposed port 8008 instead of
needing direct access to the ttyd port (7680+).
The session_id is used to look up the ttyd port from the database.
Authentication is handled via session token in the query string.
"""
import socket
# Get token from query params
token = websocket.query_params.get("token")
if not token:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Missing token")
return
# We need a DB session to look up the terminal session
# Since we're in a WebSocket endpoint, we need to create one manually
from app.core.database import SessionLocal
async with SessionLocal() as db_session:
session_repo = TerminalSessionRepository(db_session)
session = await session_repo.get(session_id)
if not session:
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Session not found")
return
# Verify token
if not terminal_service.verify_token(token, session.token_hash):
await websocket.close(code=status.WS_1008_POLICY_VIOLATION, reason="Invalid token")
return
# Accept the WebSocket connection
await websocket.accept()
# Connect to local ttyd via TCP socket
ttyd_port = session.ttyd_port
ttyd_host = "127.0.0.1" # ttyd is always local in the container
try:
# Create a TCP socket to ttyd
reader, writer = await asyncio.open_connection(ttyd_host, ttyd_port)
logger.info(f"Proxying WebSocket for session {session_id[:8]}... to ttyd on port {ttyd_port}")
# Bidirectional proxy: forward messages between WebSocket and ttyd
async def forward_ws_to_ttyd():
"""Forward WebSocket messages to ttyd."""
try:
while True:
data = await websocket.receive_bytes()
writer.write(data)
await writer.drain()
except WebSocketDisconnect:
logger.debug(f"WebSocket disconnected for session {session_id[:8]}...")
except Exception as e:
logger.error(f"Error forwarding WS to ttyd: {e}")
finally:
try:
writer.close()
await writer.wait_closed()
except Exception:
pass
async def forward_ttyd_to_ws():
"""Forward ttyd messages to WebSocket."""
try:
while True:
data = await reader.readexactly(1024)
if not data:
break
await websocket.send_bytes(data)
except asyncio.IncompleteReadError:
# Normal EOF
pass
except Exception as e:
logger.error(f"Error forwarding ttyd to WS: {e}")
finally:
try:
await websocket.close()
except Exception:
pass
# Run both directions concurrently
await asyncio.gather(
forward_ws_to_ttyd(),
forward_ttyd_to_ws(),
return_exceptions=True
)
except ConnectionRefusedError:
logger.error(f"Failed to connect to ttyd on {ttyd_host}:{ttyd_port} for session {session_id[:8]}...")
await websocket.close(code=1011, reason="ttyd connection failed")
except Exception as e:
logger.exception(f"Error in terminal WebSocket proxy: {e}")
try:
await websocket.close(code=1011, reason="Proxy error")
except Exception:
pass
+6
View File
@@ -64,6 +64,9 @@ class HostMetricsBase(BaseModel):
lvm_info: Optional[Dict[str, Any]] = None
zfs_info: Optional[Dict[str, Any]] = None
# Stockage détaillé (avec métadonnées de collecte)
storage_details: Optional[Dict[str, Any]] = None
# System info
os_name: Optional[str] = None
os_version: Optional[str] = None
@@ -130,6 +133,9 @@ class HostMetricsSummary(BaseModel):
lvm_info: Optional[Dict[str, Any]] = None
zfs_info: Optional[Dict[str, Any]] = None
# Stockage détaillé
storage_details: Optional[Dict[str, Any]] = None
# Système
os_name: Optional[str] = None
uptime_human: Optional[str] = None
+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(