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:
+135
-13
@@ -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
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+116
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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