diff --git a/ansible/inventory/hosts.yml b/ansible/inventory/hosts.yml index dbca559..dfd8bef 100644 --- a/ansible/inventory/hosts.yml +++ b/ansible/inventory/hosts.yml @@ -13,7 +13,10 @@ all: env_lab: hosts: dev.lab.home: null + media-1.lab.home: null media.labb.home: null + openclaw.lab.home: + ansible_host: 192.168.30.111 env_prod: hosts: ali2v.truenas.home: null @@ -36,7 +39,9 @@ all: dev.lab.home: null dev.prod.home: null jump.point.home: null + media-1.lab.home: null media.labb.home: null + openclaw.lab.home: null orangepi.pc.home: null raspi.4gb.home: null raspi.8gb.home: null diff --git a/app/crud/terminal_command_log.py b/app/crud/terminal_command_log.py index 15e500f..5f65b85 100644 --- a/app/crud/terminal_command_log.py +++ b/app/crud/terminal_command_log.py @@ -139,6 +139,8 @@ class TerminalCommandLogRepository: async def list_global( self, query: Optional[str] = None, + time_filter: str = "all", + pinned_only: bool = False, host_id: Optional[str] = None, user_id: Optional[str] = None, limit: int = 50, @@ -146,13 +148,6 @@ class TerminalCommandLogRepository: ) -> List[TerminalCommandLog]: """ List command logs globally with optional filters. - - Args: - query: Optional search query - host_id: Optional host ID to filter by - user_id: Optional user ID to filter by - limit: Maximum number of results - offset: Number of results to skip """ conditions = [TerminalCommandLog.is_blocked == False] @@ -164,6 +159,23 @@ class TerminalCommandLogRepository: if query: conditions.append(TerminalCommandLog.command.ilike(f"%{query}%")) + + if pinned_only: + conditions.append(TerminalCommandLog.is_pinned == True) + + if time_filter != "all": + now = datetime.now(timezone.utc) + if time_filter == "today": + cutoff = now.replace(hour=0, minute=0, second=0, microsecond=0) + elif time_filter == "week": + cutoff = now - timedelta(days=7) + elif time_filter == "month": + cutoff = now - timedelta(days=30) + else: + cutoff = None + + if cutoff: + conditions.append(TerminalCommandLog.created_at >= cutoff) stmt = ( select(TerminalCommandLog) diff --git a/app/index.html b/app/index.html index d52a5dc..3a9d511 100644 --- a/app/index.html +++ b/app/index.html @@ -3167,20 +3167,25 @@ /* Terminal History Panel */ .terminal-history-panel { + position: absolute; + top: 57px; /* Matches header height */ + left: 0; + right: 0; + z-index: 100; background: #1e1e2e; border-bottom: 1px solid #374151; - max-height: 350px; + max-height: 0; overflow: hidden; display: flex; flex-direction: column; opacity: 0; - transform: translateY(-10px); - transition: opacity 0.2s ease, transform 0.2s ease; + transition: max-height 0.3s ease, opacity 0.2s ease, transform 0.2s ease; + box-shadow: 0 5px 25px rgba(0,0,0,0.5); } .terminal-history-panel.open { + max-height: 350px; opacity: 1; - transform: translateY(0); } .terminal-history-header { @@ -3308,7 +3313,11 @@ /* Docked mode: panel stays visible and doesn't close on command execute */ .terminal-history-panel.docked { + position: relative; + top: 0; max-height: 280px; + z-index: 1; + box-shadow: none; border-bottom: 2px solid #7c3aed; } @@ -3395,6 +3404,17 @@ color: #6b7280; cursor: help; } + + .terminal-history-host { + font-size: 0.6875rem; + color: #4ade80; + background: rgba(34, 197, 94, 0.1); + padding: 0.125rem 0.375rem; + border-radius: 4px; + border: 1px solid rgba(34, 197, 94, 0.2); + margin-left: auto; + white-space: nowrap; + } .terminal-history-count { font-size: 0.625rem; diff --git a/app/main.js b/app/main.js index eb410da..79e032b 100644 --- a/app/main.js +++ b/app/main.js @@ -11726,6 +11726,8 @@ class DashboardManager { showTerminalDrawer(hostName, hostIp, loading = false) { this.terminalDrawerOpen = true; + this.terminalHistoryPanelOpen = false; + this.terminalHistoryPanelPinned = false; // Create drawer if it doesn't exist let drawer = document.getElementById('terminalDrawer'); @@ -11963,6 +11965,8 @@ class DashboardManager { } this.terminalDrawerOpen = false; + this.terminalHistoryPanelOpen = false; + this.terminalHistoryPanelPinned = false; } // ===== TERMINAL CLEANUP HANDLERS ===== @@ -12260,7 +12264,7 @@ class DashboardManager { async toggleTerminalHistory() { if (this.terminalHistoryPanelOpen) { - this.closeTerminalHistoryPanel(); + this.closeTerminalHistoryPanel(true); } else { this.openTerminalHistoryPanel(); } @@ -12292,9 +12296,9 @@ class DashboardManager { } } - closeTerminalHistoryPanel() { - // If panel is pinned/docked, don't close on command execute - if (this.terminalHistoryPanelPinned) return; + closeTerminalHistoryPanel(force = false) { + // If panel is pinned/docked, don't close on command execute unless forced + if (this.terminalHistoryPanelPinned && !force) return; const panel = document.getElementById('terminalHistoryPanel'); const btn = document.getElementById('terminalHistoryBtn'); @@ -12577,8 +12581,8 @@ class DashboardManager { const newPinned = !cmd.is_pinned; try { - const hostId = this.terminalSession.host.id; - await this.apiCall(`/api/terminal/${hostId}/command-history/${cmd.command_hash}/pin`, { + const hId = cmd.host_id || this.terminalSession.host.id; + await this.apiCall(`/api/terminal/${hId}/command-history/${cmd.command_hash}/pin`, { method: 'POST', body: JSON.stringify({ is_pinned: newPinned }) }); @@ -12620,7 +12624,7 @@ class DashboardManager { if (found) { found.term.focus(); found.term.paste(command); - // Don't close panel (insert mode) + this.closeTerminalHistoryPanel(); return; } diff --git a/app/routes/terminal.py b/app/routes/terminal.py index cf7da97..82d71e8 100644 --- a/app/routes/terminal.py +++ b/app/routes/terminal.py @@ -202,9 +202,8 @@ async def _verify_history_access( if current_user: return True - token = _get_session_token_from_request(request, host_id, token=request.query_params.get("token")) - if not token: - return False + # Try query param first (generic) + q_token = request.query_params.get("token") session_repo = TerminalSessionRepository(db_session) # Try by ID first @@ -218,7 +217,9 @@ async def _verify_history_access( sessions = await session_repo.list_active_for_host(host.id) for s in sessions: - if terminal_service.verify_token(token, s.token_hash): + # Check if token is in query param OR in the session-specific cookie + token = q_token or _get_session_token_from_request(request, s.id) + if token and terminal_service.verify_token(token, s.token_hash): return True return False @@ -1111,7 +1112,7 @@ async def get_terminal_connect_page( // ---- Panel open/close ---- function toggleHistory() { - if (historyPanelOpen && !historyPanelPinned) { closeHistoryPanel(); return; } + if (historyPanelOpen) { closeHistoryPanel(true); return; } const panel = document.getElementById('terminalHistoryPanel'); const btn = document.getElementById('btnHistory'); panel.style.display = 'flex'; panel.classList.add('open'); btn.classList.add('active'); @@ -1120,8 +1121,8 @@ async def get_terminal_connect_page( if (si) { si.focus(); si.select(); } if (historyData.length === 0) loadHistory(); } - function closeHistoryPanel() { - if (historyPanelPinned) return; + function closeHistoryPanel(force = false) { + if (historyPanelPinned && !force) return; const panel = document.getElementById('terminalHistoryPanel'); const btn = document.getElementById('btnHistory'); panel.classList.remove('open'); btn.classList.remove('active'); @@ -1174,8 +1175,9 @@ async def get_terminal_connect_page( const cmd = historyData[i]; if (!cmd || !cmd.command_hash) return; const newPinned = !cmd.is_pinned; + const hId = cmd.host_id || HOST_ID; try { - const res = await fetch(`/api/terminal/${HOST_ID}/command-history/${cmd.command_hash}/pin?token=${encodeURIComponent(TOKEN)}`, { + const res = await fetch(`/api/terminal/${hId}/command-history/${cmd.command_hash}/pin?token=${encodeURIComponent(TOKEN)}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + TOKEN }, body: JSON.stringify({ is_pinned: newPinned }) @@ -1214,10 +1216,10 @@ async def get_terminal_connect_page( const list=document.getElementById('terminalHistoryList'),q=historySearchQuery||''; if(!historyData.length){list.innerHTML=`
${q?`Aucun résultat pour "${escH(q)}"`:historyPinnedOnly?'Aucune commande épinglée':'Aucune commande'}
`;return;} list.innerHTML=historyData.map((cmd,i)=>{ - const c=cmd.command||'',ta=relTime(cmd.last_used||cmd.created_at),ec=cmd.execution_count||1,sel=i===historySelectedIndex,pinned=cmd.is_pinned; + const c=cmd.command||'',ta=relTime(cmd.last_used||cmd.created_at),ec=cmd.execution_count||1,sel=i===historySelectedIndex,pinned=cmd.is_pinned,hn=cmd.host_name||''; let dc=escH(c.length>80?c.substring(0,80)+'...':c); if(q)dc=dc.replace(new RegExp(`(${escRE(q)})`,'gi'),'$1'); - return `
${pinned?'':''}${dc}
${ta}${ec>1?`×${ec}`:''}
`; + return `
${pinned?'':''}${dc}
${ta}${ec>1?`×${ec}`:''}${hn?`${escH(hn)}`:''}
`; }).join(''); if(historySelectedIndex>=0){const s=list.querySelector('.selected');if(s)s.scrollIntoView({block:'nearest',behavior:'smooth'});} } @@ -1420,9 +1422,12 @@ async def get_host_command_history( CommandHistoryItem( id=log.id, command=log.command, + command_hash=log.command_hash, created_at=log.created_at, + host_id=log.host_id, host_name=log.host_name, username=log.username, + is_pinned=log.is_pinned, ) for log in logs ] @@ -1597,6 +1602,8 @@ async def toggle_command_pin( async def get_global_command_history( request: Request, query: Optional[str] = None, + time_filter: str = "all", + pinned_only: bool = False, host_id: Optional[str] = None, limit: int = 50, offset: int = 0, @@ -1623,6 +1630,8 @@ async def get_global_command_history( cmd_repo = TerminalCommandLogRepository(db_session) logs = await cmd_repo.list_global( query=query, + time_filter=time_filter, + pinned_only=pinned_only, host_id=host_id, user_id=str(user_id) if user_id else None, limit=min(limit, 100), @@ -1633,9 +1642,12 @@ async def get_global_command_history( CommandHistoryItem( id=log.id, command=log.command, + command_hash=log.command_hash, created_at=log.created_at, + host_id=log.host_id, host_name=log.host_name, username=log.username, + is_pinned=log.is_pinned, ) for log in logs ] diff --git a/app/schemas/terminal.py b/app/schemas/terminal.py index 6a07612..c7178a1 100644 --- a/app/schemas/terminal.py +++ b/app/schemas/terminal.py @@ -15,7 +15,9 @@ class CommandHistoryItem(BaseModel): """A single command from history.""" id: int command: str + command_hash: Optional[str] = None created_at: datetime + host_id: Optional[str] = None host_name: Optional[str] = None username: Optional[str] = None execution_count: Optional[int] = None diff --git a/app/templates/terminal/connect.html b/app/templates/terminal/connect.html index 4c9516c..587d0e2 100644 --- a/app/templates/terminal/connect.html +++ b/app/templates/terminal/connect.html @@ -191,6 +191,12 @@ } .terminal-history-meta { display: flex; align-items: center; gap: 0.5rem; flex-shrink: 0; } .terminal-history-time { font-size: 0.6875rem; color: #6b7280; } + .terminal-history-host { + font-size: 0.6875rem; color: #4ade80; + background: rgba(34, 197, 94, 0.1); padding: 0.125rem 0.375rem; + border-radius: 4px; border: 1px solid rgba(34, 197, 94, 0.2); + margin-left: auto; white-space: nowrap; + } .terminal-history-count { font-size: 0.625rem; color: #7c3aed; background: rgba(124, 58, 237, 0.2); padding: 0.125rem 0.375rem; @@ -289,11 +295,6 @@ {% if debug_panel_html %} {{ debug_panel_html | safe }} {% endif %} - {{ script_block | safe }} diff --git a/logs/tasks_logs/2026/03/03/task_181456_9a2d98_media-1.lab.home_Vérification_de_santé_completed.md b/logs/tasks_logs/2026/03/03/task_181456_9a2d98_media-1.lab.home_Vérification_de_santé_completed.md new file mode 100644 index 0000000..1ffa512 --- /dev/null +++ b/logs/tasks_logs/2026/03/03/task_181456_9a2d98_media-1.lab.home_Vérification_de_santé_completed.md @@ -0,0 +1,58 @@ +# ✅ Vérification de santé + +## Informations + +| Propriété | Valeur | +|-----------|--------| +| **ID** | `11e8f1d9d9744a8d8b4f05f7c3cb9dbc` | +| **Nom** | Vérification de santé | +| **Cible** | `media-1.lab.home` | +| **Statut** | completed | +| **Type** | Manuel | +| **Progression** | 100% | +| **Début** | 2026-03-03T18:14:51.218241+00:00 | +| **Fin** | 2026-03-03T18:14:56.699977+00:00 | +| **Durée** | 5.5s | + +## Sortie + +``` +Using /mnt/c/dev/git/python/homelab-automation-api-v2/ansible/ansible.cfg as config file + +PLAY [Health check on target host] ********************************************* + +TASK [Check if host is reachable (ping)] *************************************** +ok: [media-1.lab.home] => {"changed": false, "ping": "pong"} + +TASK [Gather minimal facts] **************************************************** +ok: [media-1.lab.home] + +TASK [Get system uptime] ******************************************************* +ok: [media-1.lab.home] => {"changed": false, "cmd": ["uptime"], "delta": "0:00:00.002924", "end": "2026-03-03 13:14:52.271861", "msg": "", "rc": 0, "start": "2026-03-03 13:14:52.268937", "stderr": "", "stderr_lines": [], "stdout": " 13:14:52 up 6 days, 5:52, 0 users, load average: 0.37, 0.36, 0.28", "stdout_lines": [" 13:14:52 up 6 days, 5:52, 0 users, load average: 0.37, 0.36, 0.28"]} + +TASK [Get disk usage] ********************************************************** +ok: [media-1.lab.home] => {"changed": false, "cmd": "df -h / | tail -1 | awk '{print $5}'", "delta": "0:00:00.003590", "end": "2026-03-03 13:14:52.721041", "msg": "", "rc": 0, "start": "2026-03-03 13:14:52.717451", "stderr": "", "stderr_lines": [], "stdout": "85%", "stdout_lines": ["85%"]} + +TASK [Get memory usage (Linux)] ************************************************ +ok: [media-1.lab.home] => {"changed": false, "cmd": "if command -v free >/dev/null 2>&1; then\n free -m | grep Mem | awk '{printf \"%.1f%%\", $3/$2 * 100}'\nelse\n # Fallback for systems without free command\n cat /proc/meminfo | awk '/MemTotal/{total=$2} /MemAvailable/{avail=$2} END{printf \"%.1f%%\", (total-avail)/total*100}'\nfi\n", "delta": "0:00:00.010986", "end": "2026-03-03 13:14:53.179070", "msg": "", "rc": 0, "start": "2026-03-03 13:14:53.168084", "stderr": "", "stderr_lines": [], "stdout": "9.3%", "stdout_lines": ["9.3%"]} + +TASK [Get CPU temperature (ARM/SBC)] ******************************************* +ok: [media-1.lab.home] => {"changed": false, "cmd": "if [ -f /sys/class/thermal/thermal_zone0/temp ]; then\n temp=$(cat /sys/class/thermal/thermal_zone0/temp)\n # Use awk instead of bc for better compatibility\n echo \"${temp}\" | awk '{printf \"%.1f°C\", $1/1000}'\nelse\n echo \"N/A\"\nfi\n", "delta": "0:00:00.002847", "end": "2026-03-03 13:14:53.629279", "msg": "", "rc": 0, "start": "2026-03-03 13:14:53.626432", "stderr": "", "stderr_lines": [], "stdout": "N/A", "stdout_lines": ["N/A"]} + +TASK [Get CPU load] ************************************************************ +ok: [media-1.lab.home] => {"changed": false, "cmd": "if [ -f /proc/loadavg ]; then\n cat /proc/loadavg | awk '{print $1}'\nelse\n uptime | awk -F'load average:' '{print $2}' | awk -F',' '{print $1}' | tr -d ' '\nfi\n", "delta": "0:00:00.003757", "end": "2026-03-03 13:14:54.084586", "msg": "", "rc": 0, "start": "2026-03-03 13:14:54.080829", "stderr": "", "stderr_lines": [], "stdout": "0.38", "stdout_lines": ["0.38"]} + +TASK [Display health status] *************************************************** +ok: [media-1.lab.home] => { + "msg": "═══════════════════════════════════════\nHost: media-1.lab.home\nStatus: OK\n═══════════════════════════════════════\nUptime: 13:14:52 up 6 days, 5:52, 0 users, load average: 0.37, 0.36, 0.28\nDisk Usage: 85%\nMemory Usage: 9.3%\nCPU Load: 0.38\nCPU Temp: N/A\n═══════════════════════════════════════\n" +} + +PLAY RECAP ********************************************************************* +media-1.lab.home : ok=8 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + + +``` + +--- +*Généré automatiquement par Homelab Automation Dashboard* +*Date: 2026-03-03T18:14:56.724153+00:00* diff --git a/logs/tasks_logs/2026/03/03/task_181706_18cec8_openclaw.lab.home_Vérification_de_santé_failed.md b/logs/tasks_logs/2026/03/03/task_181706_18cec8_openclaw.lab.home_Vérification_de_santé_failed.md new file mode 100644 index 0000000..502e8c8 --- /dev/null +++ b/logs/tasks_logs/2026/03/03/task_181706_18cec8_openclaw.lab.home_Vérification_de_santé_failed.md @@ -0,0 +1,35 @@ +# ❌ Vérification de santé + +## Informations + +| Propriété | Valeur | +|-----------|--------| +| **ID** | `e1cbbcd7f94b4314a2cada7121d88f98` | +| **Nom** | Vérification de santé | +| **Cible** | `openclaw.lab.home` | +| **Statut** | failed | +| **Type** | Manuel | +| **Progression** | 100% | +| **Début** | 2026-03-03T18:17:05.574697+00:00 | +| **Fin** | 2026-03-03T18:17:06.639749+00:00 | +| **Durée** | 1.1s | + +## Sortie + +``` +Using /mnt/c/dev/git/python/homelab-automation-api-v2/ansible/ansible.cfg as config file + +PLAY [Health check on target host] ********************************************* + +TASK [Check if host is reachable (ping)] *************************************** +fatal: [openclaw.lab.home]: UNREACHABLE! => {"changed": false, "msg": "Failed to connect to the host via ssh: Warning: Permanently added '192.168.30.111' (ED25519) to the list of known hosts.\r\nautomation@192.168.30.111: Permission denied (publickey,password).", "unreachable": true} + +PLAY RECAP ********************************************************************* +openclaw.lab.home : ok=0 changed=0 unreachable=1 failed=0 skipped=0 rescued=0 ignored=0 + + +``` + +--- +*Généré automatiquement par Homelab Automation Dashboard* +*Date: 2026-03-03T18:17:06.656631+00:00* diff --git a/logs/tasks_logs/2026/03/03/task_181851_25f12c_openclaw.lab.home_Vérification_de_santé_completed.md b/logs/tasks_logs/2026/03/03/task_181851_25f12c_openclaw.lab.home_Vérification_de_santé_completed.md new file mode 100644 index 0000000..cb441cb --- /dev/null +++ b/logs/tasks_logs/2026/03/03/task_181851_25f12c_openclaw.lab.home_Vérification_de_santé_completed.md @@ -0,0 +1,58 @@ +# ✅ Vérification de santé + +## Informations + +| Propriété | Valeur | +|-----------|--------| +| **ID** | `7a57e331ad3846d583e613c02f09cfa9` | +| **Nom** | Vérification de santé | +| **Cible** | `openclaw.lab.home` | +| **Statut** | completed | +| **Type** | Manuel | +| **Progression** | 100% | +| **Début** | 2026-03-03T18:18:46.347326+00:00 | +| **Fin** | 2026-03-03T18:18:51.383461+00:00 | +| **Durée** | 5.0s | + +## Sortie + +``` +Using /mnt/c/dev/git/python/homelab-automation-api-v2/ansible/ansible.cfg as config file + +PLAY [Health check on target host] ********************************************* + +TASK [Check if host is reachable (ping)] *************************************** +ok: [openclaw.lab.home] => {"changed": false, "ping": "pong"} + +TASK [Gather minimal facts] **************************************************** +ok: [openclaw.lab.home] + +TASK [Get system uptime] ******************************************************* +ok: [openclaw.lab.home] => {"changed": false, "cmd": ["uptime"], "delta": "0:00:00.005568", "end": "2026-03-03 13:18:49.842547", "msg": "", "rc": 0, "start": "2026-03-03 13:18:49.836979", "stderr": "", "stderr_lines": [], "stdout": " 13:18:49 up 2 days, 13:12, 2 users, load average: 0.05, 0.01, 0.00", "stdout_lines": [" 13:18:49 up 2 days, 13:12, 2 users, load average: 0.05, 0.01, 0.00"]} + +TASK [Get disk usage] ********************************************************** +ok: [openclaw.lab.home] => {"changed": false, "cmd": "df -h / | tail -1 | awk '{print $5}'", "delta": "0:00:00.005402", "end": "2026-03-03 13:18:50.270457", "msg": "", "rc": 0, "start": "2026-03-03 13:18:50.265055", "stderr": "", "stderr_lines": [], "stdout": "96%", "stdout_lines": ["96%"]} + +TASK [Get memory usage (Linux)] ************************************************ +ok: [openclaw.lab.home] => {"changed": false, "cmd": "if command -v free >/dev/null 2>&1; then\n free -m | grep Mem | awk '{printf \"%.1f%%\", $3/$2 * 100}'\nelse\n # Fallback for systems without free command\n cat /proc/meminfo | awk '/MemTotal/{total=$2} /MemAvailable/{avail=$2} END{printf \"%.1f%%\", (total-avail)/total*100}'\nfi\n", "delta": "0:00:00.006000", "end": "2026-03-03 13:18:50.670294", "msg": "", "rc": 0, "start": "2026-03-03 13:18:50.664294", "stderr": "", "stderr_lines": [], "stdout": "34.0%", "stdout_lines": ["34.0%"]} + +TASK [Get CPU temperature (ARM/SBC)] ******************************************* +ok: [openclaw.lab.home] => {"changed": false, "cmd": "if [ -f /sys/class/thermal/thermal_zone0/temp ]; then\n temp=$(cat /sys/class/thermal/thermal_zone0/temp)\n # Use awk instead of bc for better compatibility\n echo \"${temp}\" | awk '{printf \"%.1f°C\", $1/1000}'\nelse\n echo \"N/A\"\nfi\n", "delta": "0:00:00.003576", "end": "2026-03-03 13:18:51.050744", "msg": "", "rc": 0, "start": "2026-03-03 13:18:51.047168", "stderr": "", "stderr_lines": [], "stdout": "N/A", "stdout_lines": ["N/A"]} + +TASK [Get CPU load] ************************************************************ +ok: [openclaw.lab.home] => {"changed": false, "cmd": "if [ -f /proc/loadavg ]; then\n cat /proc/loadavg | awk '{print $1}'\nelse\n uptime | awk -F'load average:' '{print $2}' | awk -F',' '{print $1}' | tr -d ' '\nfi\n", "delta": "0:00:00.004421", "end": "2026-03-03 13:18:51.419246", "msg": "", "rc": 0, "start": "2026-03-03 13:18:51.414825", "stderr": "", "stderr_lines": [], "stdout": "0.05", "stdout_lines": ["0.05"]} + +TASK [Display health status] *************************************************** +ok: [openclaw.lab.home] => { + "msg": "═══════════════════════════════════════\nHost: openclaw.lab.home\nStatus: OK\n═══════════════════════════════════════\nUptime: 13:18:49 up 2 days, 13:12, 2 users, load average: 0.05, 0.01, 0.00\nDisk Usage: 96%\nMemory Usage: 34.0%\nCPU Load: 0.05\nCPU Temp: N/A\n═══════════════════════════════════════\n" +} + +PLAY RECAP ********************************************************************* +openclaw.lab.home : ok=8 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 + + +``` + +--- +*Généré automatiquement par Homelab Automation Dashboard* +*Date: 2026-03-03T18:18:51.401631+00:00*