feat: Implement Homelab Automation API v2, introducing a new dashboard, comprehensive backend models, and API routes.
Some checks failed
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
Some checks failed
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:
parent
984d06a223
commit
817f8b4ee7
785
app/dashboard.html
Normal file
785
app/dashboard.html
Normal file
@ -0,0 +1,785 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Homelab — Mission Control</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/animejs/3.2.1/anime.min.js"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;500;600;700;800&family=DM+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<meta name="theme-color" content="#0a0f1e">
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: { heading: ['Syne','sans-serif'], body: ['DM Sans','sans-serif'] },
|
||||
colors: {
|
||||
base: { 900:'#0a0f1e', 800:'#0f1629', 700:'#141c34', 600:'#1a2340' },
|
||||
accent: { cyan:'#22d3ee', violet:'#8b5cf6', pink:'#ec4899' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
:root {
|
||||
--bg-base:#0a0f1e; --bg-card:rgba(255,255,255,0.03); --bg-card-hover:rgba(255,255,255,0.06);
|
||||
--border:rgba(255,255,255,0.08); --border-hover:rgba(255,255,255,0.15);
|
||||
--text-primary:#e2e8f0; --text-secondary:#94a3b8; --text-muted:#64748b;
|
||||
--accent-cyan:#22d3ee; --accent-violet:#8b5cf6; --accent-green:#10b981;
|
||||
--accent-red:#ef4444; --accent-yellow:#f59e0b; --accent-pink:#ec4899;
|
||||
--sidebar-w:260px; --sidebar-collapsed:68px; --header-h:52px;
|
||||
--glass-blur:blur(20px); --glass-bg:rgba(255,255,255,0.03); --glass-border:1px solid rgba(255,255,255,0.08);
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--bg-base:#f1f5f9; --bg-card:rgba(255,255,255,0.8); --bg-card-hover:rgba(255,255,255,0.95);
|
||||
--border:rgba(0,0,0,0.08); --border-hover:rgba(0,0,0,0.15);
|
||||
--text-primary:#0f172a; --text-secondary:#475569; --text-muted:#94a3b8;
|
||||
--glass-bg:rgba(255,255,255,0.7); --glass-border:1px solid rgba(0,0,0,0.08);
|
||||
}
|
||||
* { margin:0; padding:0; box-sizing:border-box; }
|
||||
html { scroll-behavior:smooth; }
|
||||
body { font-family:'DM Sans',sans-serif; background:var(--bg-base); color:var(--text-primary); min-height:100vh; overflow-x:hidden; transition:background .4s,color .4s; }
|
||||
h1,h2,h3,h4,h5,h6,.font-heading { font-family:'Syne',sans-serif; }
|
||||
::-webkit-scrollbar { width:6px; height:6px; }
|
||||
::-webkit-scrollbar-track { background:transparent; }
|
||||
::-webkit-scrollbar-thumb { background:rgba(139,92,246,.3); border-radius:3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background:rgba(139,92,246,.5); }
|
||||
|
||||
/* Sidebar */
|
||||
#sidebar { position:fixed; left:0; top:0; bottom:0; width:var(--sidebar-w); background:linear-gradient(180deg,rgba(15,22,41,.95),rgba(10,15,30,.98)); backdrop-filter:var(--glass-blur); border-right:var(--glass-border); z-index:40; transition:width .35s cubic-bezier(.4,0,.2,1); display:flex; flex-direction:column; }
|
||||
#sidebar.collapsed { width:var(--sidebar-collapsed); }
|
||||
#sidebar .nav-label { white-space:nowrap; overflow:hidden; opacity:1; transition:opacity .25s; }
|
||||
#sidebar.collapsed .nav-label { opacity:0; width:0; }
|
||||
#sidebar .logo-text { white-space:nowrap; overflow:hidden; opacity:1; transition:opacity .25s; }
|
||||
#sidebar.collapsed .logo-text { opacity:0; width:0; }
|
||||
.nav-item { display:flex; align-items:center; gap:12px; padding:10px 16px; margin:2px 8px; border-radius:10px; color:var(--text-secondary); cursor:pointer; transition:all .2s; font-size:.875rem; font-weight:500; position:relative; }
|
||||
.nav-item:hover { background:rgba(139,92,246,.08); color:var(--text-primary); }
|
||||
.nav-item.active { background:rgba(139,92,246,.15); color:#a78bfa; box-shadow:inset 0 0 0 1px rgba(139,92,246,.25); }
|
||||
.nav-item i { width:20px; text-align:center; font-size:1rem; flex-shrink:0; }
|
||||
.nav-divider { height:1px; background:linear-gradient(90deg,transparent,rgba(139,92,246,.2),transparent); margin:8px 16px; }
|
||||
|
||||
/* Header */
|
||||
#header { position:fixed; top:0; right:0; left:var(--sidebar-w); height:var(--header-h); background:rgba(10,15,30,.8); backdrop-filter:var(--glass-blur); border-bottom:var(--glass-border); z-index:35; display:flex; align-items:center; padding:0 24px; gap:16px; transition:left .35s cubic-bezier(.4,0,.2,1); }
|
||||
body.sidebar-collapsed #header { left:var(--sidebar-collapsed); }
|
||||
|
||||
/* Main */
|
||||
#main { margin-left:var(--sidebar-w); margin-top:var(--header-h); padding:24px; min-height:calc(100vh - var(--header-h)); transition:margin-left .35s cubic-bezier(.4,0,.2,1); }
|
||||
body.sidebar-collapsed #main { margin-left:var(--sidebar-collapsed); }
|
||||
|
||||
/* Cards */
|
||||
.glass-card { background:var(--glass-bg); backdrop-filter:var(--glass-blur); border:var(--glass-border); border-radius:16px; transition:all .3s ease; }
|
||||
.glass-card:hover { border-color:var(--border-hover); }
|
||||
|
||||
/* Widget grid */
|
||||
.widget-grid { display:grid; gap:20px; grid-template-columns:repeat(var(--grid-cols,3),1fr); }
|
||||
.widget { border-radius:16px; background:var(--glass-bg); backdrop-filter:var(--glass-blur); border:var(--glass-border); overflow:hidden; transition:all .3s ease,transform .2s; }
|
||||
.widget:hover { border-color:var(--border-hover); }
|
||||
.widget.dragging { opacity:.6; transform:scale(1.02); box-shadow:0 0 30px rgba(139,92,246,.2); z-index:100; }
|
||||
.widget-header { display:flex; align-items:center; justify-content:space-between; padding:16px 20px 12px; cursor:grab; user-select:none; }
|
||||
.widget-header:active { cursor:grabbing; }
|
||||
.widget-body { padding:0 20px 16px; }
|
||||
.widget.span-2 { grid-column:span 2; }
|
||||
|
||||
/* Status dot */
|
||||
.status-dot { width:8px; height:8px; border-radius:50%; display:inline-block; }
|
||||
.status-dot.online { background:var(--accent-green); box-shadow:0 0 8px rgba(16,185,129,.5); }
|
||||
.status-dot.offline { background:var(--accent-red); box-shadow:0 0 8px rgba(239,68,68,.5); }
|
||||
.status-dot.warning { background:var(--accent-yellow); box-shadow:0 0 8px rgba(245,158,11,.5); }
|
||||
|
||||
/* Sparkline */
|
||||
.sparkline { display:inline-block; vertical-align:middle; }
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar { height:6px; background:rgba(255,255,255,.06); border-radius:3px; overflow:hidden; }
|
||||
.progress-fill { height:100%; border-radius:3px; transition:width .6s ease; }
|
||||
|
||||
/* Toast */
|
||||
.toast-container { position:fixed; bottom:24px; right:24px; z-index:9999; display:flex; flex-direction:column-reverse; gap:8px; }
|
||||
.toast { background:rgba(15,22,41,.95); backdrop-filter:blur(20px); border:1px solid rgba(255,255,255,.1); border-radius:12px; padding:14px 20px; min-width:300px; box-shadow:0 10px 40px rgba(0,0,0,.4); display:flex; align-items:flex-start; gap:12px; }
|
||||
.toast-progress { position:absolute; bottom:0; left:0; height:3px; border-radius:0 0 12px 12px; }
|
||||
|
||||
/* Command palette */
|
||||
#cmd-palette { position:fixed; inset:0; z-index:200; background:rgba(0,0,0,.6); backdrop-filter:blur(8px); display:none; align-items:flex-start; justify-content:center; padding-top:15vh; }
|
||||
#cmd-palette.open { display:flex; }
|
||||
#cmd-box { width:560px; max-height:420px; background:rgba(15,22,41,.97); border:1px solid rgba(139,92,246,.3); border-radius:16px; box-shadow:0 25px 60px rgba(0,0,0,.5); overflow:hidden; }
|
||||
#cmd-input { width:100%; padding:16px 20px; background:transparent; border:none; border-bottom:1px solid rgba(255,255,255,.08); color:var(--text-primary); font-size:1rem; outline:none; font-family:'DM Sans',sans-serif; }
|
||||
#cmd-results { max-height:320px; overflow-y:auto; padding:8px; }
|
||||
.cmd-item { display:flex; align-items:center; gap:12px; padding:10px 14px; border-radius:10px; cursor:pointer; color:var(--text-secondary); transition:all .15s; }
|
||||
.cmd-item:hover,.cmd-item.selected { background:rgba(139,92,246,.12); color:var(--text-primary); }
|
||||
.cmd-item i { width:20px; text-align:center; color:var(--accent-violet); }
|
||||
|
||||
/* Focus overlay */
|
||||
#focus-overlay { position:fixed; inset:0; z-index:150; background:rgba(0,0,0,.7); backdrop-filter:blur(8px); display:none; align-items:center; justify-content:center; padding:40px; }
|
||||
#focus-overlay.open { display:flex; }
|
||||
#focus-content { width:90vw; max-width:1200px; max-height:85vh; overflow-y:auto; background:rgba(15,22,41,.97); border:1px solid rgba(139,92,246,.2); border-radius:20px; padding:32px; }
|
||||
|
||||
/* Customize drawer */
|
||||
#customize-drawer { position:fixed; top:0; right:-360px; bottom:0; width:360px; background:rgba(15,22,41,.97); backdrop-filter:blur(20px); border-left:1px solid rgba(139,92,246,.15); z-index:50; transition:right .35s cubic-bezier(.4,0,.2,1); overflow-y:auto; padding:24px; }
|
||||
#customize-drawer.open { right:0; }
|
||||
#drawer-overlay { position:fixed; inset:0; background:rgba(0,0,0,.4); z-index:45; display:none; }
|
||||
#drawer-overlay.open { display:block; }
|
||||
|
||||
/* Live feed */
|
||||
.feed-item { display:flex; gap:12px; padding:10px 0; border-bottom:1px solid rgba(255,255,255,.04); animation:feedIn .3s ease; }
|
||||
@keyframes feedIn { from { opacity:0; transform:translateX(-10px); } to { opacity:1; transform:translateX(0); } }
|
||||
.feed-time { font-size:.75rem; color:var(--text-muted); white-space:nowrap; min-width:40px; font-family:'Syne',sans-serif; }
|
||||
.feed-badge { padding:2px 8px; border-radius:6px; font-size:.7rem; font-weight:600; text-transform:uppercase; letter-spacing:.03em; }
|
||||
|
||||
/* KPI card */
|
||||
.kpi-card { padding:20px; border-radius:14px; background:var(--glass-bg); border:var(--glass-border); transition:all .3s; }
|
||||
.kpi-card:hover { transform:translateY(-3px); border-color:var(--border-hover); }
|
||||
.kpi-value { font-family:'Syne',sans-serif; font-size:2rem; font-weight:700; line-height:1; }
|
||||
.kpi-label { font-size:.8rem; color:var(--text-muted); margin-top:4px; }
|
||||
|
||||
/* Theme toggle */
|
||||
.theme-toggle { width:44px; height:24px; border-radius:12px; background:rgba(255,255,255,.1); border:1px solid rgba(255,255,255,.15); cursor:pointer; position:relative; transition:background .3s; }
|
||||
.theme-toggle .dot { width:18px; height:18px; border-radius:50%; background:#8b5cf6; position:absolute; top:2px; left:2px; transition:transform .3s; }
|
||||
[data-theme="light"] .theme-toggle .dot { transform:translateX(20px); background:#f59e0b; }
|
||||
|
||||
/* Action btn */
|
||||
.action-btn { display:inline-flex; align-items:center; gap:8px; padding:10px 18px; border-radius:10px; font-size:.85rem; font-weight:600; cursor:pointer; border:1px solid var(--border); background:var(--glass-bg); color:var(--text-primary); transition:all .2s; }
|
||||
.action-btn:hover { transform:translateY(-2px); border-color:var(--border-hover); box-shadow:0 8px 24px rgba(0,0,0,.2); }
|
||||
|
||||
/* Column selector */
|
||||
.col-btn { width:32px; height:32px; border-radius:8px; border:1px solid var(--border); background:transparent; color:var(--text-muted); cursor:pointer; display:inline-flex; align-items:center; justify-content:center; font-size:.75rem; font-weight:700; transition:all .2s; font-family:'Syne',sans-serif; }
|
||||
.col-btn.active { background:rgba(139,92,246,.2); color:#a78bfa; border-color:rgba(139,92,246,.4); }
|
||||
|
||||
/* Status bar metric */
|
||||
.sys-metric { display:flex; align-items:center; gap:6px; font-size:.75rem; color:var(--text-secondary); }
|
||||
.sys-metric .metric-val { font-family:'Syne',sans-serif; font-weight:600; color:var(--text-primary); }
|
||||
|
||||
/* Light theme overrides */
|
||||
[data-theme="light"] #sidebar { background:linear-gradient(180deg,rgba(241,245,249,.97),rgba(226,232,240,.98)); }
|
||||
[data-theme="light"] #header { background:rgba(241,245,249,.9); }
|
||||
[data-theme="light"] .widget,.glass-card,[data-theme="light"] .kpi-card { background:var(--bg-card); }
|
||||
[data-theme="light"] .toast { background:rgba(255,255,255,.97); border-color:rgba(0,0,0,.1); }
|
||||
[data-theme="light"] #cmd-box { background:rgba(255,255,255,.97); border-color:rgba(139,92,246,.2); }
|
||||
[data-theme="light"] #focus-content { background:rgba(255,255,255,.97); }
|
||||
[data-theme="light"] #customize-drawer { background:rgba(241,245,249,.97); }
|
||||
[data-theme="light"] .nav-item { color:var(--text-secondary); }
|
||||
[data-theme="light"] .nav-item:hover { background:rgba(139,92,246,.06); color:var(--text-primary); }
|
||||
[data-theme="light"] .nav-item.active { background:rgba(139,92,246,.1); color:#7c3aed; }
|
||||
|
||||
@media (max-width:1024px) { .widget-grid { --grid-cols:2 !important; } #sidebar { width:var(--sidebar-collapsed); } #sidebar .nav-label,#sidebar .logo-text { opacity:0; width:0; } #header { left:var(--sidebar-collapsed); } #main { margin-left:var(--sidebar-collapsed); } }
|
||||
@media (max-width:640px) { .widget-grid { --grid-cols:1 !important; } .widget.span-2 { grid-column:span 1; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- SIDEBAR -->
|
||||
<div id="sidebar">
|
||||
<div class="flex items-center gap-3 px-5 py-4 border-b border-white/5">
|
||||
<div class="w-9 h-9 rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400 flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-satellite-dish text-white text-sm"></i>
|
||||
</div>
|
||||
<span class="logo-text font-heading font-bold text-base text-white tracking-tight">Mission Control</span>
|
||||
</div>
|
||||
<nav class="flex-1 py-3 overflow-y-auto" id="sidebar-nav">
|
||||
<div class="px-4 mb-2"><span class="nav-label text-[.65rem] font-semibold uppercase tracking-wider text-white/25">Navigation</span></div>
|
||||
<div class="nav-item active" data-page="dashboard" onclick="navTo('dashboard')"><i class="fas fa-grip"></i><span class="nav-label">Dashboard</span></div>
|
||||
<div class="nav-item" data-page="hosts" onclick="navTo('hosts')"><i class="fas fa-server"></i><span class="nav-label">Hosts</span></div>
|
||||
<div class="nav-item" data-page="containers" onclick="navTo('containers')"><i class="fab fa-docker"></i><span class="nav-label">Containers</span></div>
|
||||
<div class="nav-item" data-page="scheduler" onclick="navTo('scheduler')"><i class="fas fa-calendar-alt"></i><span class="nav-label">Planificateur</span></div>
|
||||
<div class="nav-item" data-page="console" onclick="navTo('console')"><i class="fas fa-terminal"></i><span class="nav-label">Console</span></div>
|
||||
<div class="nav-divider"></div>
|
||||
<div class="px-4 mb-2 mt-2"><span class="nav-label text-[.65rem] font-semibold uppercase tracking-wider text-white/25">Système</span></div>
|
||||
<div class="nav-item" data-page="metrics" onclick="navTo('metrics')"><i class="fas fa-chart-area"></i><span class="nav-label">Métriques</span></div>
|
||||
<div class="nav-item" data-page="settings" onclick="navTo('settings')"><i class="fas fa-cog"></i><span class="nav-label">Paramètres</span></div>
|
||||
</nav>
|
||||
<div class="p-3 border-t border-white/5">
|
||||
<div class="nav-item" onclick="toggleSidebar()" id="sidebar-toggle"><i class="fas fa-angles-left"></i><span class="nav-label">Réduire</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HEADER -->
|
||||
<header id="header">
|
||||
<div class="flex items-center gap-4 flex-1">
|
||||
<div class="sys-metric"><i class="fas fa-microchip text-cyan-400"></i><span>CPU</span><span class="metric-val" id="sys-cpu">—</span></div>
|
||||
<div class="sys-metric"><i class="fas fa-memory text-violet-400"></i><span>RAM</span><span class="metric-val" id="sys-ram">—</span></div>
|
||||
<div class="sys-metric"><i class="fas fa-network-wired text-green-400"></i><span>Réseau</span><span class="metric-val" id="sys-net">—</span></div>
|
||||
<div class="sys-metric" id="ws-status"><span class="status-dot" id="ws-dot"></span><span id="ws-label">WS</span></div>
|
||||
</div>
|
||||
<button onclick="openCmdPalette()" class="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-white/10 text-sm text-gray-400 hover:border-violet-500/40 hover:text-gray-200 transition-all" title="Ctrl+K">
|
||||
<i class="fas fa-search text-xs"></i><span class="hidden sm:inline">Rechercher...</span><kbd class="ml-2 text-[.65rem] bg-white/5 px-1.5 py-0.5 rounded border border-white/10">⌘K</kbd>
|
||||
</button>
|
||||
<div class="theme-toggle" onclick="toggleTheme()" title="Changer de thème"><div class="dot"></div></div>
|
||||
<button onclick="openCustomizeDrawer()" class="text-gray-400 hover:text-white transition-colors" title="Personnaliser"><i class="fas fa-sliders"></i></button>
|
||||
</header>
|
||||
|
||||
<!-- MAIN CONTENT -->
|
||||
<main id="main">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="font-heading text-2xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p class="text-sm text-gray-500 mt-1">Vue d'ensemble de votre infrastructure</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-gray-500 mr-2">Colonnes</span>
|
||||
<button class="col-btn" data-cols="2" onclick="setGridCols(2)">2</button>
|
||||
<button class="col-btn active" data-cols="3" onclick="setGridCols(3)">3</button>
|
||||
<button class="col-btn" data-cols="4" onclick="setGridCols(4)">4</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Widget Grid -->
|
||||
<div class="widget-grid" id="widget-grid"></div>
|
||||
</main>
|
||||
|
||||
<!-- COMMAND PALETTE -->
|
||||
<div id="cmd-palette" onclick="if(event.target===this)closeCmdPalette()">
|
||||
<div id="cmd-box">
|
||||
<input id="cmd-input" type="text" placeholder="Rechercher hosts, containers, actions..." autocomplete="off">
|
||||
<div id="cmd-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FOCUS OVERLAY -->
|
||||
<div id="focus-overlay" onclick="if(event.target===this)closeFocus()">
|
||||
<div id="focus-content"></div>
|
||||
</div>
|
||||
|
||||
<!-- CUSTOMIZE DRAWER -->
|
||||
<div id="drawer-overlay" onclick="closeCustomizeDrawer()"></div>
|
||||
<div id="customize-drawer">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="font-heading text-lg font-bold">Personnaliser</h3>
|
||||
<button onclick="closeCustomizeDrawer()" class="text-gray-400 hover:text-white"><i class="fas fa-xmark text-lg"></i></button>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mb-4">Activez ou désactivez les widgets affichés sur votre dashboard.</p>
|
||||
<div id="widget-toggles"></div>
|
||||
<div class="mt-6 pt-4 border-t border-white/5">
|
||||
<button onclick="resetLayout()" class="action-btn w-full justify-center text-sm"><i class="fas fa-rotate-left"></i> Réinitialiser la disposition</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- TOAST CONTAINER -->
|
||||
<div class="toast-container" id="toast-container"></div>
|
||||
<script>
|
||||
// ======================== MOCK DATA ========================
|
||||
const MOCK = {
|
||||
hosts: [
|
||||
{ id:'h1', name:'hp.nas.home', status:'online', cpu:35, ram:77, disk:24, uptime:'9h 14m', os:'Ubuntu 22.04' },
|
||||
{ id:'h2', name:'dev.lab.home', status:'online', cpu:12, ram:45, disk:61, uptime:'9h 02m', os:'Debian 12' },
|
||||
{ id:'h3', name:'dev.prod.home', status:'online', cpu:8, ram:32, disk:38, uptime:'9h 02m', os:'Ubuntu 24.04' },
|
||||
{ id:'h4', name:'proxmox-01', status:'online', cpu:55, ram:89, disk:72, uptime:'12h 33m', os:'Proxmox 8.1' },
|
||||
{ id:'h5', name:'media-1.lab.home', status:'online', cpu:22, ram:60, disk:45, uptime:'6h 45m', os:'Debian 12' },
|
||||
{ id:'h6', name:'jump.point.home', status:'offline', cpu:0, ram:0, disk:0, uptime:'—', os:'Alpine 3.18' },
|
||||
],
|
||||
containers: [
|
||||
{ id:'c1', name:'cloudbeaver', port:8888, status:'running', host:'dev.lab.home', host_id:'h2', image:'dbeaver/cloudbeaver:latest' },
|
||||
{ id:'c2', name:'portainer', port:9000, status:'running', host:'dev.prod.home', host_id:'h3', image:'portainer/portainer-ce:latest' },
|
||||
{ id:'c3', name:'nextcloud', port:8080, status:'running', host:'hp.nas.home', host_id:'h1', image:'nextcloud:28' },
|
||||
{ id:'c4', name:'vaultwarden', port:8083, status:'running', host:'hp.nas.home', host_id:'h1', image:'vaultwarden/server:latest' },
|
||||
{ id:'c5', name:'gitea', port:3001, status:'stopped', host:'dev.lab.home', host_id:'h2', image:'gitea/gitea:1.21' },
|
||||
{ id:'c6', name:'grafana', port:3000, status:'running', host:'proxmox-01', host_id:'h4', image:'grafana/grafana:10' },
|
||||
{ id:'c7', name:'prometheus', port:9090, status:'running', host:'proxmox-01', host_id:'h4', image:'prom/prometheus:latest' },
|
||||
],
|
||||
stats: { hostsOnline:18, hostsTotal:20, tasksExecuted:142, successRate:100, uptime:99.9 },
|
||||
executions: [
|
||||
{ id:'e1', cmd:'hostname', type:'Ad-hoc', host:'dev.lab.home', status:'error', ago:'5s', ts:Date.now()-5000 },
|
||||
{ id:'e2', cmd:'docker ps', type:'Ad-hoc', host:'dev.docker', status:'success', ago:'2m', ts:Date.now()-120000 },
|
||||
{ id:'e3', cmd:'ansible ping', type:'Playbook', host:'all', status:'success', ago:'5m', ts:Date.now()-300000 },
|
||||
{ id:'e4', cmd:'apt update', type:'Ad-hoc', host:'hp.nas.home', status:'success', ago:'15m', ts:Date.now()-900000 },
|
||||
{ id:'e5', cmd:'backup.yml', type:'Playbook', host:'proxmox-01', status:'success', ago:'1h', ts:Date.now()-3600000 },
|
||||
],
|
||||
schedules: [
|
||||
{ id:'s1', name:'Backup quotidien', playbook:'backup.yml', cron:'0 2 * * *', enabled:true, last_status:'success', next_run:'Dans 8h', targets:'all' },
|
||||
{ id:'s2', name:'Mise à jour sécurité', playbook:'security-update.yml', cron:'0 4 * * 0', enabled:true, last_status:'success', next_run:'Dans 3j', targets:'prod' },
|
||||
{ id:'s3', name:'Nettoyage Docker', playbook:'docker-cleanup.yml', cron:'0 3 * * 1', enabled:false, last_status:'warning', next_run:'Désactivé', targets:'docker-hosts' },
|
||||
{ id:'s4', name:'Vérif. santé', playbook:'health-check.yml', cron:'*/30 * * * *', enabled:true, last_status:'success', next_run:'Dans 12m', targets:'all' },
|
||||
],
|
||||
liveEvents: [
|
||||
{ type:'task_completed', message:'Backup quotidien terminé', host:'all', status:'success', ts:Date.now()-60000 },
|
||||
{ type:'host_updated', message:'Métriques mises à jour', host:'proxmox-01', status:'info', ts:Date.now()-180000 },
|
||||
{ type:'alert_created', message:'Utilisation RAM élevée (89%)', host:'proxmox-01', status:'warning', ts:Date.now()-300000 },
|
||||
{ type:'task_completed', message:'docker ps exécuté', host:'dev.docker', status:'success', ts:Date.now()-420000 },
|
||||
]
|
||||
};
|
||||
|
||||
// ======================== STATE ========================
|
||||
let state = {
|
||||
hosts: [], containers: [], stats: {}, executions: [], schedules: [], liveEvents: [],
|
||||
gridCols: parseInt(localStorage.getItem('mc_gridCols')) || 3,
|
||||
sidebarCollapsed: localStorage.getItem('mc_sidebar') === 'collapsed',
|
||||
theme: localStorage.getItem('mc_theme') || 'dark',
|
||||
widgetOrder: JSON.parse(localStorage.getItem('mc_widgetOrder') || 'null'),
|
||||
hiddenWidgets: JSON.parse(localStorage.getItem('mc_hiddenWidgets') || '[]'),
|
||||
cmdOpen: false, focusOpen: false, drawerOpen: false, ws: null, wsConnected: false,
|
||||
cmdSelectedIdx: 0
|
||||
};
|
||||
|
||||
// ======================== API ========================
|
||||
async function fetchAPI(endpoint) {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers = { 'Content-Type':'application/json' };
|
||||
if (token) headers['Authorization'] = `Bearer ${token}`;
|
||||
try {
|
||||
const res = await fetch(`${location.protocol}//${location.host}${endpoint}`, { headers });
|
||||
if (!res.ok) throw new Error(res.statusText);
|
||||
return await res.json();
|
||||
} catch(e) {
|
||||
console.warn(`API ${endpoint} indisponible, données mock utilisées`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
const [hostsRes, containersRes, schedulesRes, healthRes, execsRes] = await Promise.allSettled([
|
||||
fetchAPI('/api/hosts'), fetchAPI('/api/docker/containers'),
|
||||
fetchAPI('/api/schedules'), fetchAPI('/api/health/global'),
|
||||
fetchAPI('/api/adhoc/history?limit=10')
|
||||
]);
|
||||
state.hosts = hostsRes.value?.hosts || hostsRes.value || MOCK.hosts;
|
||||
if (Array.isArray(state.hosts) && state.hosts.length === 0) state.hosts = MOCK.hosts;
|
||||
|
||||
const cRes = containersRes.value;
|
||||
state.containers = cRes?.containers || cRes || MOCK.containers;
|
||||
if (Array.isArray(state.containers) && state.containers.length === 0) state.containers = MOCK.containers;
|
||||
|
||||
state.schedules = schedulesRes.value?.schedules || schedulesRes.value || MOCK.schedules;
|
||||
if (Array.isArray(state.schedules) && state.schedules.length === 0) state.schedules = MOCK.schedules;
|
||||
|
||||
const h = healthRes.value;
|
||||
if (h) {
|
||||
state.stats = { hostsOnline: h.hosts_online ?? h.online ?? MOCK.stats.hostsOnline, hostsTotal: h.hosts_total ?? h.total ?? MOCK.stats.hostsOnline+2, tasksExecuted: h.tasks_executed ?? MOCK.stats.tasksExecuted, successRate: h.success_rate ?? MOCK.stats.successRate, uptime: h.uptime ?? MOCK.stats.uptime };
|
||||
} else { state.stats = MOCK.stats; }
|
||||
|
||||
const eRes = execsRes.value;
|
||||
state.executions = eRes?.commands || eRes || MOCK.executions;
|
||||
if (Array.isArray(state.executions) && state.executions.length === 0) state.executions = MOCK.executions;
|
||||
|
||||
state.liveEvents = [...MOCK.liveEvents];
|
||||
renderAllWidgets();
|
||||
updateSysMetrics();
|
||||
}
|
||||
|
||||
// ======================== WEBSOCKET ========================
|
||||
function connectWS() {
|
||||
try {
|
||||
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
state.ws = new WebSocket(`${proto}//${location.host}/ws`);
|
||||
state.ws.onopen = () => { state.wsConnected = true; updateWSIndicator(); };
|
||||
state.ws.onclose = () => { state.wsConnected = false; updateWSIndicator(); setTimeout(connectWS, 3000); };
|
||||
state.ws.onerror = () => { state.wsConnected = false; updateWSIndicator(); };
|
||||
state.ws.onmessage = (e) => {
|
||||
try {
|
||||
const msg = JSON.parse(e.data);
|
||||
state.liveEvents.unshift({ type:msg.type, message:msg.data?.message || msg.type, host:msg.data?.host || '—', status:msg.data?.status || 'info', ts:Date.now() });
|
||||
if (state.liveEvents.length > 50) state.liveEvents.length = 50;
|
||||
renderWidget('live-feed');
|
||||
} catch(err) {}
|
||||
};
|
||||
} catch(e) { state.wsConnected = false; updateWSIndicator(); }
|
||||
}
|
||||
function updateWSIndicator() {
|
||||
const dot = document.getElementById('ws-dot');
|
||||
const lbl = document.getElementById('ws-label');
|
||||
if (state.wsConnected) { dot.className='status-dot online'; lbl.textContent='Connecté'; }
|
||||
else { dot.className='status-dot offline'; lbl.textContent='Déconnecté'; }
|
||||
}
|
||||
|
||||
// ======================== UTILITIES ========================
|
||||
function timeAgo(ts) {
|
||||
const s = Math.floor((Date.now() - ts) / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.floor(s/60)}m`;
|
||||
if (s < 86400) return `${Math.floor(s/3600)}h`;
|
||||
return `${Math.floor(s/86400)}j`;
|
||||
}
|
||||
|
||||
function sparklineSVG(w=80, h=28, color='#22d3ee', points=20) {
|
||||
const data = Array.from({length:points}, () => Math.random());
|
||||
const max = Math.max(...data, 0.01);
|
||||
const step = w / (points - 1);
|
||||
const pts = data.map((v, i) => `${i*step},${h - (v/max)*(h-4)}`).join(' ');
|
||||
return `<svg class="sparkline" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><polyline fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points="${pts}"/><polyline fill="url(#sg)" stroke="none" points="0,${h} ${pts} ${w},${h}"/><defs><linearGradient id="sg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${color}" stop-opacity=".15"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs></svg>`;
|
||||
}
|
||||
|
||||
function progressColor(val) {
|
||||
if (val > 80) return 'bg-red-500';
|
||||
if (val > 60) return 'bg-yellow-500';
|
||||
return 'bg-cyan-400';
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const m = { success:'bg-emerald-500/20 text-emerald-400', error:'bg-red-500/20 text-red-400', warning:'bg-yellow-500/20 text-yellow-400', running:'bg-blue-500/20 text-blue-400', stopped:'bg-gray-500/20 text-gray-400', info:'bg-cyan-500/20 text-cyan-400' };
|
||||
return m[status] || m.info;
|
||||
}
|
||||
|
||||
function showToast(message, type='success', duration=3500) {
|
||||
const container = document.getElementById('toast-container');
|
||||
const icons = { success:'fa-check-circle text-emerald-400', error:'fa-times-circle text-red-400', warning:'fa-exclamation-triangle text-yellow-400', info:'fa-info-circle text-cyan-400' };
|
||||
const id = 'toast-' + Date.now();
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast relative';
|
||||
el.id = id;
|
||||
el.innerHTML = `<i class="fas ${icons[type]||icons.info} text-lg mt-0.5"></i><div class="flex-1"><div class="text-sm font-medium">${message}</div></div><button onclick="this.closest('.toast').remove()" class="text-gray-500 hover:text-white ml-2"><i class="fas fa-xmark"></i></button><div class="toast-progress ${type==='error'?'bg-red-500':type==='warning'?'bg-yellow-500':'bg-cyan-400'}" id="${id}-bar" style="width:100%"></div>`;
|
||||
container.appendChild(el);
|
||||
anime({ targets:`#${id}`, translateX:[80,0], opacity:[0,1], duration:400, easing:'easeOutCubic' });
|
||||
anime({ targets:`#${id}-bar`, width:'0%', duration:duration, easing:'linear', complete:()=>{ anime({ targets:`#${id}`, translateX:80, opacity:0, duration:300, easing:'easeInCubic', complete:()=>el.remove() }); } });
|
||||
}
|
||||
|
||||
function updateSysMetrics() {
|
||||
const h = state.hosts.length ? state.hosts : MOCK.hosts;
|
||||
const online = h.filter(x => x.status === 'online');
|
||||
const avgCpu = online.length ? Math.round(online.reduce((s,x)=>s+(x.cpu||0),0)/online.length) : 0;
|
||||
const avgRam = online.length ? Math.round(online.reduce((s,x)=>s+(x.ram||0),0)/online.length) : 0;
|
||||
document.getElementById('sys-cpu').textContent = avgCpu + '%';
|
||||
document.getElementById('sys-ram').textContent = avgRam + '%';
|
||||
document.getElementById('sys-net').textContent = online.length + '/' + h.length;
|
||||
}
|
||||
// ======================== WIDGET DEFINITIONS ========================
|
||||
const WIDGETS = {
|
||||
'stats-overview': { title:'Vue d\'ensemble', icon:'fa-chart-pie', color:'text-cyan-400', span:2, render:renderStatsWidget },
|
||||
'live-feed': { title:'Activité en direct', icon:'fa-bolt', color:'text-yellow-400', render:renderLiveFeed },
|
||||
'containers': { title:'Containers favoris', icon:'fa-docker fab', color:'text-blue-400', render:renderContainers },
|
||||
'hosts': { title:'Gestion des Hosts', icon:'fa-server', color:'text-emerald-400', span:2, render:renderHosts },
|
||||
'console': { title:'Console Ad-Hoc', icon:'fa-terminal', color:'text-violet-400', render:renderConsole },
|
||||
'scheduler': { title:'Planificateur', icon:'fa-calendar-alt', color:'text-pink-400', render:renderScheduler },
|
||||
'actions': { title:'Actions Rapides', icon:'fa-rocket', color:'text-orange-400', render:renderActions },
|
||||
};
|
||||
|
||||
const DEFAULT_ORDER = ['stats-overview','live-feed','containers','hosts','console','scheduler','actions'];
|
||||
|
||||
function getWidgetOrder() {
|
||||
return state.widgetOrder || DEFAULT_ORDER;
|
||||
}
|
||||
|
||||
function renderAllWidgets() {
|
||||
const grid = document.getElementById('widget-grid');
|
||||
grid.style.setProperty('--grid-cols', state.gridCols);
|
||||
grid.innerHTML = '';
|
||||
const order = getWidgetOrder().filter(id => !state.hiddenWidgets.includes(id));
|
||||
order.forEach((id, idx) => {
|
||||
const def = WIDGETS[id];
|
||||
if (!def) return;
|
||||
const w = document.createElement('div');
|
||||
w.className = `widget ${def.span ? 'span-2' : ''}`;
|
||||
w.id = `widget-${id}`;
|
||||
w.dataset.widgetId = id;
|
||||
w.draggable = true;
|
||||
w.innerHTML = `<div class="widget-header" ondblclick="openFocus('${id}')"><div class="flex items-center gap-3"><i class="fas ${def.icon} ${def.color}"></i><h3 class="font-heading font-semibold text-sm">${def.title}</h3></div><div class="flex items-center gap-2"><button onclick="openFocus('${id}')" class="text-gray-600 hover:text-gray-300 text-xs" title="Agrandir"><i class="fas fa-expand"></i></button></div></div><div class="widget-body" id="wb-${id}"></div>`;
|
||||
grid.appendChild(w);
|
||||
});
|
||||
// Render each widget
|
||||
order.filter(id => !state.hiddenWidgets.includes(id)).forEach(id => { WIDGETS[id]?.render(); });
|
||||
setupDragDrop();
|
||||
renderWidgetToggles();
|
||||
animateWidgets();
|
||||
}
|
||||
|
||||
function renderWidget(id) {
|
||||
WIDGETS[id]?.render();
|
||||
}
|
||||
|
||||
function animateWidgets() {
|
||||
anime({ targets:'.widget', translateY:[30,0], opacity:[0,1], delay:anime.stagger(60, {start:100}), duration:600, easing:'easeOutCubic' });
|
||||
}
|
||||
|
||||
// ======================== WIDGET RENDERERS ========================
|
||||
function renderStatsWidget() {
|
||||
const s = state.stats;
|
||||
const el = document.getElementById('wb-stats-overview');
|
||||
if (!el) return;
|
||||
el.innerHTML = `<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div class="kpi-card"><div class="flex items-center justify-between mb-2"><i class="fas fa-server text-cyan-400 text-lg"></i>${sparklineSVG(64,24,'#22d3ee')}</div><div class="kpi-value text-cyan-400">${s.hostsOnline||0}</div><div class="kpi-label">Hosts en ligne</div></div>
|
||||
<div class="kpi-card"><div class="flex items-center justify-between mb-2"><i class="fas fa-list-check text-violet-400 text-lg"></i>${sparklineSVG(64,24,'#8b5cf6')}</div><div class="kpi-value text-violet-400">${s.tasksExecuted||0}</div><div class="kpi-label">Tâches exécutées</div></div>
|
||||
<div class="kpi-card"><div class="flex items-center justify-between mb-2"><i class="fas fa-bullseye text-emerald-400 text-lg"></i>${sparklineSVG(64,24,'#10b981')}</div><div class="kpi-value text-emerald-400">${s.successRate||0}%</div><div class="kpi-label">Taux de succès</div></div>
|
||||
<div class="kpi-card"><div class="flex items-center justify-between mb-2"><i class="fas fa-signal text-pink-400 text-lg"></i>${sparklineSVG(64,24,'#ec4899')}</div><div class="kpi-value text-pink-400">${s.uptime||0}%</div><div class="kpi-label">Disponibilité</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderLiveFeed() {
|
||||
const el = document.getElementById('wb-live-feed');
|
||||
if (!el) return;
|
||||
const events = state.liveEvents.slice(0, 12);
|
||||
el.innerHTML = `<div style="max-height:320px;overflow-y:auto">${events.length === 0 ? '<div class="text-center text-gray-600 py-8"><i class="fas fa-satellite-dish text-2xl mb-2"></i><p class="text-sm">En attente d\'événements...</p></div>' : events.map(ev => `<div class="feed-item"><div class="feed-time">${timeAgo(ev.ts)}</div><div class="flex-1"><div class="text-sm">${ev.message}</div><div class="text-xs text-gray-500 mt-0.5">${ev.host}</div></div><span class="feed-badge ${statusBadge(ev.status)}">${ev.status}</span></div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderContainers() {
|
||||
const el = document.getElementById('wb-containers');
|
||||
if (!el) return;
|
||||
const ctrs = (state.containers || []).slice(0, 6);
|
||||
el.innerHTML = `<div class="space-y-2">${ctrs.map(c => `<div class="flex items-center justify-between p-3 rounded-xl bg-white/[.02] border border-white/[.04] hover:border-white/10 transition-all group">
|
||||
<div class="flex items-center gap-3"><span class="status-dot ${c.status==='running'?'online':'offline'}"></span><div><div class="text-sm font-medium">${c.name}</div><div class="text-xs text-gray-500">${c.host||''} ${c.port?':'+c.port:''}</div></div></div>
|
||||
<div class="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">${c.status==='running'?`<button onclick="containerAction('${c.host_id||''}','${c.id}','restart')" class="w-7 h-7 rounded-lg bg-white/5 hover:bg-yellow-500/20 text-gray-400 hover:text-yellow-400 flex items-center justify-center text-xs" title="Redémarrer"><i class="fas fa-rotate"></i></button><button onclick="containerAction('${c.host_id||''}','${c.id}','stop')" class="w-7 h-7 rounded-lg bg-white/5 hover:bg-red-500/20 text-gray-400 hover:text-red-400 flex items-center justify-center text-xs" title="Arrêter"><i class="fas fa-stop"></i></button>`:`<button onclick="containerAction('${c.host_id||''}','${c.id}','start')" class="w-7 h-7 rounded-lg bg-white/5 hover:bg-emerald-500/20 text-gray-400 hover:text-emerald-400 flex items-center justify-center text-xs" title="Démarrer"><i class="fas fa-play"></i></button>`}</div>
|
||||
</div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderHosts() {
|
||||
const el = document.getElementById('wb-hosts');
|
||||
if (!el) return;
|
||||
const hosts = state.hosts.slice(0, 8);
|
||||
el.innerHTML = `<div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="text-xs text-gray-500 border-b border-white/5"><th class="text-left pb-2 font-medium">Hôte</th><th class="text-left pb-2 font-medium">Statut</th><th class="text-left pb-2 font-medium">CPU</th><th class="text-left pb-2 font-medium">RAM</th><th class="text-left pb-2 font-medium">Disque</th><th class="text-left pb-2 font-medium">Uptime</th></tr></thead><tbody>${hosts.map(h => `<tr class="border-b border-white/[.03] hover:bg-white/[.02] transition-colors"><td class="py-2.5"><div class="flex items-center gap-2"><span class="status-dot ${h.status==='online'?'online':'offline'}"></span><span class="font-medium">${h.name}</span></div></td><td><span class="feed-badge ${statusBadge(h.status==='online'?'success':'error')}">${h.status}</span></td><td><div class="flex items-center gap-2"><div class="progress-bar w-16"><div class="progress-fill ${progressColor(h.cpu||0)}" style="width:${h.cpu||0}%"></div></div><span class="text-xs text-gray-400">${h.cpu||0}%</span></div></td><td><div class="flex items-center gap-2"><div class="progress-bar w-16"><div class="progress-fill ${progressColor(h.ram||0)}" style="width:${h.ram||0}%"></div></div><span class="text-xs text-gray-400">${h.ram||0}%</span></div></td><td><div class="flex items-center gap-2"><div class="progress-bar w-16"><div class="progress-fill ${progressColor(h.disk||0)}" style="width:${h.disk||0}%"></div></div><span class="text-xs text-gray-400">${h.disk||0}%</span></div></td><td class="text-gray-400">${h.uptime||'—'}</td></tr>`).join('')}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function renderConsole() {
|
||||
const el = document.getElementById('wb-console');
|
||||
if (!el) return;
|
||||
const execs = (state.executions || []).slice(0, 6);
|
||||
el.innerHTML = `<div class="space-y-2">${execs.map(e => `<div class="flex items-center justify-between p-2.5 rounded-lg bg-white/[.02] border border-white/[.04]"><div class="flex items-center gap-3"><span class="w-7 h-7 rounded-lg ${e.status==='success'?'bg-emerald-500/15 text-emerald-400':'bg-red-500/15 text-red-400'} flex items-center justify-center text-xs"><i class="fas ${e.status==='success'?'fa-check':'fa-xmark'}"></i></span><div><div class="text-sm font-mono">${e.cmd||e.command||''}</div><div class="text-xs text-gray-500">${e.host||e.target||''} · ${e.type||'Ad-hoc'}</div></div></div><span class="text-xs text-gray-500">${e.ago||timeAgo(e.ts||Date.now())}</span></div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderScheduler() {
|
||||
const el = document.getElementById('wb-scheduler');
|
||||
if (!el) return;
|
||||
const scheds = (state.schedules || []).slice(0, 5);
|
||||
el.innerHTML = `<div class="space-y-2">${scheds.map(s => `<div class="flex items-center justify-between p-3 rounded-xl bg-white/[.02] border border-white/[.04]"><div class="flex items-center gap-3"><span class="w-8 h-8 rounded-lg ${s.enabled?'bg-violet-500/15 text-violet-400':'bg-gray-500/15 text-gray-500'} flex items-center justify-center"><i class="fas ${s.enabled?'fa-clock':'fa-pause'}"></i></span><div><div class="text-sm font-medium">${s.name}</div><div class="text-xs text-gray-500">${s.cron||''} · ${s.targets||'all'}</div></div></div><div class="text-right"><div class="text-xs ${s.enabled?'text-cyan-400':'text-gray-600'}">${s.next_run||'—'}</div><span class="feed-badge mt-1 ${statusBadge(s.last_status||'info')}">${s.last_status||'—'}</span></div></div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderActions() {
|
||||
const el = document.getElementById('wb-actions');
|
||||
if (!el) return;
|
||||
const actions = [
|
||||
{ label:'Mise à jour', icon:'fa-download', color:'from-cyan-500 to-blue-500', action:'update' },
|
||||
{ label:'Redémarrer', icon:'fa-rotate', color:'from-yellow-500 to-orange-500', action:'restart' },
|
||||
{ label:'Backup', icon:'fa-database', color:'from-emerald-500 to-teal-500', action:'backup' },
|
||||
{ label:'Santé', icon:'fa-heart-pulse', color:'from-pink-500 to-rose-500', action:'health' },
|
||||
{ label:'Sync Hosts', icon:'fa-arrows-rotate', color:'from-violet-500 to-purple-500', action:'sync' },
|
||||
{ label:'Nettoyage', icon:'fa-broom', color:'from-amber-500 to-yellow-500', action:'cleanup' },
|
||||
];
|
||||
el.innerHTML = `<div class="grid grid-cols-2 gap-3">${actions.map(a => `<button onclick="triggerAction('${a.action}')" class="action-btn flex-col gap-2 py-4 hover:scale-[1.03] active:scale-100 group"><div class="w-10 h-10 rounded-xl bg-gradient-to-br ${a.color} flex items-center justify-center text-white shadow-lg group-hover:shadow-xl transition-shadow"><i class="fas ${a.icon}"></i></div><span class="text-xs">${a.label}</span></button>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
async function containerAction(hostId, containerId, action) {
|
||||
showToast(`${action} du container en cours...`, 'info');
|
||||
const res = await fetchAPI(`/api/docker/containers/${hostId}/${containerId}/${action}`);
|
||||
if (res) { showToast(`Container ${action} effectué`, 'success'); await loadData(); }
|
||||
else showToast(`Échec: ${action}`, 'error');
|
||||
}
|
||||
|
||||
async function triggerAction(action) {
|
||||
const msgs = { update:'Mise à jour déclenchée', restart:'Redémarrage en cours', backup:'Backup lancé', health:'Vérification santé lancée', sync:'Synchronisation des hosts...', cleanup:'Nettoyage Docker lancé' };
|
||||
showToast(msgs[action] || 'Action exécutée', 'info');
|
||||
let endpoint = null;
|
||||
if (action === 'sync') endpoint = '/api/hosts/sync';
|
||||
if (action === 'health') endpoint = '/api/health/refresh';
|
||||
if (action === 'cleanup') endpoint = '/api/docker/collect-all';
|
||||
if (endpoint) {
|
||||
const res = await fetch(`${location.protocol}//${location.host}${endpoint}`, { method:'POST', headers:{ 'Content-Type':'application/json', ...(localStorage.getItem('accessToken')?{'Authorization':`Bearer ${localStorage.getItem('accessToken')}`}:{}) } }).catch(()=>null);
|
||||
if (res?.ok) showToast('Action terminée avec succès', 'success');
|
||||
else showToast('Action simulée (API indisponible)', 'warning');
|
||||
} else {
|
||||
setTimeout(() => showToast('Action simulée avec succès', 'success'), 1500);
|
||||
}
|
||||
}
|
||||
// ======================== DRAG & DROP ========================
|
||||
function setupDragDrop() {
|
||||
const widgets = document.querySelectorAll('.widget');
|
||||
let dragSrc = null;
|
||||
widgets.forEach(w => {
|
||||
w.addEventListener('dragstart', function(e) {
|
||||
dragSrc = this;
|
||||
this.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', this.dataset.widgetId);
|
||||
});
|
||||
w.addEventListener('dragend', function() { this.classList.remove('dragging'); });
|
||||
w.addEventListener('dragover', function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; this.style.borderColor = 'rgba(139,92,246,.4)'; });
|
||||
w.addEventListener('dragleave', function() { this.style.borderColor = ''; });
|
||||
w.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
this.style.borderColor = '';
|
||||
if (dragSrc === this) return;
|
||||
const grid = document.getElementById('widget-grid');
|
||||
const allW = [...grid.children];
|
||||
const fromIdx = allW.indexOf(dragSrc);
|
||||
const toIdx = allW.indexOf(this);
|
||||
if (fromIdx < toIdx) this.after(dragSrc);
|
||||
else this.before(dragSrc);
|
||||
saveWidgetOrder();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function saveWidgetOrder() {
|
||||
const order = [...document.querySelectorAll('.widget')].map(w => w.dataset.widgetId);
|
||||
state.widgetOrder = order;
|
||||
localStorage.setItem('mc_widgetOrder', JSON.stringify(order));
|
||||
}
|
||||
|
||||
// ======================== SIDEBAR ========================
|
||||
function toggleSidebar() {
|
||||
state.sidebarCollapsed = !state.sidebarCollapsed;
|
||||
const sb = document.getElementById('sidebar');
|
||||
const icon = document.querySelector('#sidebar-toggle i');
|
||||
if (state.sidebarCollapsed) {
|
||||
sb.classList.add('collapsed');
|
||||
document.body.classList.add('sidebar-collapsed');
|
||||
icon.className = 'fas fa-angles-right';
|
||||
} else {
|
||||
sb.classList.remove('collapsed');
|
||||
document.body.classList.remove('sidebar-collapsed');
|
||||
icon.className = 'fas fa-angles-left';
|
||||
}
|
||||
localStorage.setItem('mc_sidebar', state.sidebarCollapsed ? 'collapsed' : 'expanded');
|
||||
}
|
||||
|
||||
function navTo(page) {
|
||||
document.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.page === page));
|
||||
// For now only dashboard is shown, others could navigate away
|
||||
if (page !== 'dashboard' && window.parent !== window) {
|
||||
window.parent.location.hash = page === 'hosts' ? '#page-hosts' : page === 'containers' ? '#page-docker' : page === 'scheduler' ? '#page-schedules' : page === 'console' ? '#dashboard' : page === 'metrics' ? '#page-docker' : page === 'settings' ? '#page-configuration' : '#dashboard';
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== GRID COLUMNS ========================
|
||||
function setGridCols(n) {
|
||||
state.gridCols = n;
|
||||
document.getElementById('widget-grid').style.setProperty('--grid-cols', n);
|
||||
document.querySelectorAll('.col-btn').forEach(b => b.classList.toggle('active', parseInt(b.dataset.cols) === n));
|
||||
localStorage.setItem('mc_gridCols', n);
|
||||
}
|
||||
|
||||
// ======================== THEME ========================
|
||||
function toggleTheme() {
|
||||
state.theme = state.theme === 'dark' ? 'light' : 'dark';
|
||||
document.documentElement.setAttribute('data-theme', state.theme);
|
||||
localStorage.setItem('mc_theme', state.theme);
|
||||
anime({ targets:'body', duration:400, easing:'easeInOutQuad' });
|
||||
}
|
||||
|
||||
// ======================== COMMAND PALETTE ========================
|
||||
function openCmdPalette() {
|
||||
state.cmdOpen = true;
|
||||
document.getElementById('cmd-palette').classList.add('open');
|
||||
const input = document.getElementById('cmd-input');
|
||||
input.value = '';
|
||||
input.focus();
|
||||
state.cmdSelectedIdx = 0;
|
||||
renderCmdResults('');
|
||||
anime({ targets:'#cmd-box', scale:[.95,1], opacity:[0,1], duration:200, easing:'easeOutCubic' });
|
||||
}
|
||||
function closeCmdPalette() {
|
||||
state.cmdOpen = false;
|
||||
anime({ targets:'#cmd-box', scale:.95, opacity:0, duration:150, easing:'easeInCubic', complete:()=>document.getElementById('cmd-palette').classList.remove('open') });
|
||||
}
|
||||
|
||||
function getCmdItems(query) {
|
||||
const items = [];
|
||||
(state.hosts||[]).forEach(h => items.push({ type:'host', label:h.name, sub:`Host · ${h.status}`, icon:'fa-server', action:()=>showToast(`Sélectionné: ${h.name}`,'info') }));
|
||||
(state.containers||[]).forEach(c => items.push({ type:'container', label:c.name, sub:`Container · ${c.host||''}`, icon:'fa-docker fab', action:()=>showToast(`Container: ${c.name}`,'info') }));
|
||||
items.push({ type:'action', label:'Sync Hosts', sub:'Synchroniser l\'inventaire', icon:'fa-arrows-rotate', action:()=>{closeCmdPalette();triggerAction('sync');} });
|
||||
items.push({ type:'action', label:'Health Check', sub:'Vérifier la santé', icon:'fa-heart-pulse', action:()=>{closeCmdPalette();triggerAction('health');} });
|
||||
items.push({ type:'action', label:'Backup', sub:'Lancer un backup', icon:'fa-database', action:()=>{closeCmdPalette();triggerAction('backup');} });
|
||||
items.push({ type:'action', label:'Toggle Thème', sub:'Changer clair/sombre', icon:'fa-circle-half-stroke', action:()=>{closeCmdPalette();toggleTheme();} });
|
||||
if (!query) return items.slice(0, 12);
|
||||
const q = query.toLowerCase();
|
||||
return items.filter(i => i.label.toLowerCase().includes(q) || (i.sub||'').toLowerCase().includes(q)).slice(0, 12);
|
||||
}
|
||||
|
||||
function renderCmdResults(query) {
|
||||
const results = getCmdItems(query);
|
||||
const el = document.getElementById('cmd-results');
|
||||
state.cmdSelectedIdx = Math.min(state.cmdSelectedIdx, results.length - 1);
|
||||
if (state.cmdSelectedIdx < 0) state.cmdSelectedIdx = 0;
|
||||
el.innerHTML = results.map((r, i) => `<div class="cmd-item ${i===state.cmdSelectedIdx?'selected':''}" onclick="cmdItems[${i}].action()" data-idx="${i}"><i class="fas ${r.icon}"></i><div><div class="text-sm">${r.label}</div><div class="text-xs text-gray-500">${r.sub||''}</div></div></div>`).join('');
|
||||
window.cmdItems = results;
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); state.cmdOpen ? closeCmdPalette() : openCmdPalette(); }
|
||||
if (e.key === 'Escape') { if (state.cmdOpen) closeCmdPalette(); if (state.focusOpen) closeFocus(); if (state.drawerOpen) closeCustomizeDrawer(); }
|
||||
if (state.cmdOpen) {
|
||||
const items = document.querySelectorAll('.cmd-item');
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); state.cmdSelectedIdx = Math.min(state.cmdSelectedIdx+1, items.length-1); renderCmdResults(document.getElementById('cmd-input').value); }
|
||||
if (e.key === 'ArrowUp') { e.preventDefault(); state.cmdSelectedIdx = Math.max(state.cmdSelectedIdx-1, 0); renderCmdResults(document.getElementById('cmd-input').value); }
|
||||
if (e.key === 'Enter' && window.cmdItems?.[state.cmdSelectedIdx]) { e.preventDefault(); window.cmdItems[state.cmdSelectedIdx].action(); }
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('cmd-input')?.addEventListener('input', e => { state.cmdSelectedIdx = 0; renderCmdResults(e.target.value); });
|
||||
});
|
||||
|
||||
// ======================== FOCUS MODE ========================
|
||||
function openFocus(widgetId) {
|
||||
state.focusOpen = true;
|
||||
const def = WIDGETS[widgetId];
|
||||
if (!def) return;
|
||||
const el = document.getElementById('focus-content');
|
||||
el.innerHTML = `<div class="flex items-center justify-between mb-6"><div class="flex items-center gap-3"><i class="fas ${def.icon} ${def.color} text-xl"></i><h2 class="font-heading text-xl font-bold">${def.title}</h2></div><button onclick="closeFocus()" class="w-9 h-9 rounded-xl bg-white/5 hover:bg-white/10 flex items-center justify-center text-gray-400 hover:text-white transition-colors"><i class="fas fa-xmark"></i></button></div><div id="focus-body"></div>`;
|
||||
// Re-render with more data
|
||||
const focusBody = document.getElementById('focus-body');
|
||||
if (widgetId === 'hosts') {
|
||||
const hosts = state.hosts;
|
||||
focusBody.innerHTML = `<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">${hosts.map(h => `<div class="p-4 rounded-xl bg-white/[.03] border border-white/[.06]"><div class="flex items-center justify-between mb-3"><div class="flex items-center gap-2"><span class="status-dot ${h.status==='online'?'online':'offline'}"></span><span class="font-heading font-semibold">${h.name}</span></div><span class="text-xs text-gray-500">${h.os||''}</span></div><div class="space-y-2"><div class="flex items-center justify-between text-sm"><span class="text-gray-500">CPU</span><div class="flex items-center gap-2"><div class="progress-bar w-24"><div class="progress-fill ${progressColor(h.cpu||0)}" style="width:${h.cpu||0}%"></div></div><span>${h.cpu||0}%</span></div></div><div class="flex items-center justify-between text-sm"><span class="text-gray-500">RAM</span><div class="flex items-center gap-2"><div class="progress-bar w-24"><div class="progress-fill ${progressColor(h.ram||0)}" style="width:${h.ram||0}%"></div></div><span>${h.ram||0}%</span></div></div><div class="flex items-center justify-between text-sm"><span class="text-gray-500">Disque</span><div class="flex items-center gap-2"><div class="progress-bar w-24"><div class="progress-fill ${progressColor(h.disk||0)}" style="width:${h.disk||0}%"></div></div><span>${h.disk||0}%</span></div></div><div class="text-xs text-gray-500 mt-2">Uptime: ${h.uptime||'—'}</div></div></div>`).join('')}</div>`;
|
||||
} else if (widgetId === 'stats-overview') {
|
||||
focusBody.innerHTML = document.getElementById('wb-stats-overview')?.innerHTML || '';
|
||||
focusBody.innerHTML += `<div class="mt-6 grid grid-cols-2 gap-4">${state.hosts.slice(0,4).map(h=>`<div class="p-4 rounded-xl bg-white/[.03] border border-white/[.06]"><div class="font-heading font-semibold mb-2">${h.name}</div><div class="flex gap-4">${sparklineSVG(120,40,'#22d3ee',30)} ${sparklineSVG(120,40,'#8b5cf6',30)}</div><div class="flex gap-4 mt-1 text-xs text-gray-500"><span>CPU 24h</span><span>RAM 24h</span></div></div>`).join('')}</div>`;
|
||||
} else {
|
||||
focusBody.innerHTML = document.getElementById(`wb-${widgetId}`)?.innerHTML || '<p class="text-gray-500">Contenu détaillé non disponible</p>';
|
||||
}
|
||||
document.getElementById('focus-overlay').classList.add('open');
|
||||
anime({ targets:'#focus-content', scale:[.9,1], opacity:[0,1], duration:300, easing:'easeOutCubic' });
|
||||
}
|
||||
function closeFocus() {
|
||||
state.focusOpen = false;
|
||||
anime({ targets:'#focus-content', scale:.9, opacity:0, duration:200, easing:'easeInCubic', complete:()=>document.getElementById('focus-overlay').classList.remove('open') });
|
||||
}
|
||||
|
||||
// ======================== CUSTOMIZE DRAWER ========================
|
||||
function openCustomizeDrawer() {
|
||||
state.drawerOpen = true;
|
||||
document.getElementById('customize-drawer').classList.add('open');
|
||||
document.getElementById('drawer-overlay').classList.add('open');
|
||||
anime({ targets:'#customize-drawer', translateX:[360,0], duration:350, easing:'easeOutCubic' });
|
||||
}
|
||||
function closeCustomizeDrawer() {
|
||||
state.drawerOpen = false;
|
||||
anime({ targets:'#customize-drawer', translateX:360, duration:250, easing:'easeInCubic', complete:()=>{ document.getElementById('customize-drawer').classList.remove('open'); document.getElementById('drawer-overlay').classList.remove('open'); } });
|
||||
}
|
||||
|
||||
function renderWidgetToggles() {
|
||||
const el = document.getElementById('widget-toggles');
|
||||
if (!el) return;
|
||||
el.innerHTML = Object.entries(WIDGETS).map(([id, def]) => {
|
||||
const checked = !state.hiddenWidgets.includes(id);
|
||||
return `<label class="flex items-center justify-between p-3 rounded-xl hover:bg-white/[.03] cursor-pointer transition-colors mb-1"><div class="flex items-center gap-3"><i class="fas ${def.icon} ${def.color}"></i><span class="text-sm font-medium">${def.title}</span></div><input type="checkbox" ${checked?'checked':''} onchange="toggleWidget('${id}',this.checked)" class="w-4 h-4 accent-violet-500 rounded"></label>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function toggleWidget(id, show) {
|
||||
if (show) state.hiddenWidgets = state.hiddenWidgets.filter(w => w !== id);
|
||||
else if (!state.hiddenWidgets.includes(id)) state.hiddenWidgets.push(id);
|
||||
localStorage.setItem('mc_hiddenWidgets', JSON.stringify(state.hiddenWidgets));
|
||||
renderAllWidgets();
|
||||
}
|
||||
|
||||
function resetLayout() {
|
||||
state.widgetOrder = null;
|
||||
state.hiddenWidgets = [];
|
||||
state.gridCols = 3;
|
||||
localStorage.removeItem('mc_widgetOrder');
|
||||
localStorage.removeItem('mc_hiddenWidgets');
|
||||
localStorage.setItem('mc_gridCols', '3');
|
||||
document.querySelectorAll('.col-btn').forEach(b => b.classList.toggle('active', parseInt(b.dataset.cols) === 3));
|
||||
renderAllWidgets();
|
||||
showToast('Disposition réinitialisée', 'success');
|
||||
closeCustomizeDrawer();
|
||||
}
|
||||
|
||||
// ======================== INIT ========================
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Apply saved theme
|
||||
if (state.theme === 'light') document.documentElement.setAttribute('data-theme', 'light');
|
||||
// Apply saved sidebar state
|
||||
if (state.sidebarCollapsed) { document.getElementById('sidebar').classList.add('collapsed'); document.body.classList.add('sidebar-collapsed'); document.querySelector('#sidebar-toggle i').className = 'fas fa-angles-right'; }
|
||||
// Apply saved grid cols
|
||||
setGridCols(state.gridCols);
|
||||
// Load data and render
|
||||
loadData();
|
||||
// Connect WebSocket
|
||||
connectWS();
|
||||
// Refresh data periodically
|
||||
setInterval(loadData, 60000);
|
||||
// Pulse animation for system metrics
|
||||
setInterval(() => {
|
||||
anime({ targets:'.sys-metric .metric-val', scale:[1, 1.08, 1], duration:600, easing:'easeInOutQuad' });
|
||||
}, 5000);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
534
app/dashboard_pro.js
Normal file
534
app/dashboard_pro.js
Normal file
@ -0,0 +1,534 @@
|
||||
// ======================== NAVIGATION (must be defined FIRST) ========================
|
||||
let currentPage = 'dashboard';
|
||||
|
||||
function navigateTo(pageName) {
|
||||
// Hide all pages
|
||||
document.querySelectorAll('.page-section').forEach(p => {
|
||||
p.classList.remove('active');
|
||||
});
|
||||
// Show target page
|
||||
const target = document.getElementById('page-' + pageName);
|
||||
if (target) {
|
||||
target.classList.add('active');
|
||||
}
|
||||
// Update sidebar active state
|
||||
document.querySelectorAll('#sidebar-nav .nav-item[data-page]').forEach(item => {
|
||||
item.classList.toggle('active', item.dataset.page === pageName);
|
||||
});
|
||||
// Update mobile nav active state
|
||||
document.querySelectorAll('.mobile-nav-link[data-page]').forEach(item => {
|
||||
item.classList.toggle('active', item.dataset.page === pageName);
|
||||
});
|
||||
currentPage = pageName;
|
||||
window.location.hash = pageName === 'dashboard' ? '' : pageName;
|
||||
|
||||
// Trigger page-specific data loading
|
||||
if (window.dashboard) {
|
||||
switch (pageName) {
|
||||
case 'hosts': if (typeof dashboard.renderHosts === 'function') dashboard.renderHosts(); break;
|
||||
case 'playbooks': if (typeof dashboard.renderPlaybooks === 'function') dashboard.renderPlaybooks(); break;
|
||||
case 'tasks': if (typeof dashboard.renderTasks === 'function') dashboard.renderTasks(); break;
|
||||
case 'schedules': if (typeof dashboard.renderSchedules === 'function') dashboard.renderSchedules(); break;
|
||||
case 'docker':
|
||||
if (window.dockerSection && typeof window.dockerSection.init === 'function') window.dockerSection.init();
|
||||
break;
|
||||
case 'logs': if (typeof dashboard.renderLogs === 'function') dashboard.renderLogs(); break;
|
||||
case 'alerts': if (typeof dashboard.refreshAlerts === 'function') dashboard.refreshAlerts(); break;
|
||||
case 'configuration': if (typeof dashboard.loadMetricsCollectionSchedule === 'function') dashboard.loadMetricsCollectionSchedule(); break;
|
||||
case 'dashboard': renderAllWidgets(); break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
document.body.classList.toggle('sidebar-collapsed');
|
||||
const icon = document.querySelector('#sidebar-toggle i');
|
||||
const label = document.querySelector('#sidebar-toggle .nav-label');
|
||||
if (document.body.classList.contains('sidebar-collapsed')) {
|
||||
if (icon) icon.className = 'fas fa-angles-right';
|
||||
if (label) label.textContent = 'Déplier';
|
||||
} else {
|
||||
if (icon) icon.className = 'fas fa-angles-left';
|
||||
if (label) label.textContent = 'Réduire';
|
||||
}
|
||||
localStorage.setItem('sidebarCollapsed', document.body.classList.contains('sidebar-collapsed'));
|
||||
}
|
||||
|
||||
function openCmdPalette() {
|
||||
const palette = document.getElementById('cmd-palette');
|
||||
if (palette) {
|
||||
palette.classList.add('open');
|
||||
const input = document.getElementById('cmd-input');
|
||||
if (input) { input.value = ''; input.focus(); }
|
||||
renderCmdResults('');
|
||||
}
|
||||
}
|
||||
|
||||
function closeCmdPalette() {
|
||||
const palette = document.getElementById('cmd-palette');
|
||||
if (palette) palette.classList.remove('open');
|
||||
}
|
||||
|
||||
function renderCmdResults(query) {
|
||||
const container = document.getElementById('cmd-results');
|
||||
if (!container) return;
|
||||
const q = (query || '').toLowerCase().trim();
|
||||
|
||||
// Navigation items
|
||||
const pages = [
|
||||
{ label: 'Dashboard', icon: 'fa-grip', page: 'dashboard' },
|
||||
{ label: 'Hosts', icon: 'fa-server', page: 'hosts' },
|
||||
{ label: 'Playbooks', icon: 'fa-book', page: 'playbooks' },
|
||||
{ label: 'Tâches', icon: 'fa-list-check', page: 'tasks' },
|
||||
{ label: 'Planificateur', icon: 'fa-calendar-alt', page: 'schedules' },
|
||||
{ label: 'Docker', icon: 'fa-docker fab', page: 'docker' },
|
||||
{ label: 'Logs', icon: 'fa-file-alt', page: 'logs' },
|
||||
{ label: 'Alertes', icon: 'fa-bell', page: 'alerts' },
|
||||
{ label: 'Paramètres', icon: 'fa-cog', page: 'configuration' },
|
||||
];
|
||||
|
||||
let results = pages;
|
||||
|
||||
// Add hosts to search
|
||||
const hosts = window.dashboard ? window.dashboard.hosts || [] : [];
|
||||
hosts.forEach(h => {
|
||||
results.push({ label: h.name || h.hostname, icon: 'fa-server', page: 'hosts', sub: h.ansible_host || '' });
|
||||
});
|
||||
|
||||
if (q) {
|
||||
results = results.filter(r => r.label.toLowerCase().includes(q) || (r.sub && r.sub.toLowerCase().includes(q)));
|
||||
}
|
||||
|
||||
container.innerHTML = results.slice(0, 10).map(r =>
|
||||
`<div class="cmd-result" onclick="navigateTo('${r.page}');closeCmdPalette()">
|
||||
<i class="fas ${r.icon} text-gray-500 mr-3"></i>
|
||||
<div><div class="text-sm">${r.label}</div>${r.sub ? `<div class="text-xs text-gray-500">${r.sub}</div>` : ''}</div>
|
||||
</div>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
// Keyboard shortcut for command palette
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
openCmdPalette();
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
closeCmdPalette();
|
||||
if (state.focusOpen) closeFocus();
|
||||
}
|
||||
});
|
||||
|
||||
// Command palette search input
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const cmdInput = document.getElementById('cmd-input');
|
||||
if (cmdInput) {
|
||||
cmdInput.addEventListener('input', function() {
|
||||
renderCmdResults(this.value);
|
||||
});
|
||||
cmdInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
const first = document.querySelector('.cmd-result');
|
||||
if (first) first.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Restore sidebar state
|
||||
if (localStorage.getItem('sidebarCollapsed') === 'true') {
|
||||
document.body.classList.add('sidebar-collapsed');
|
||||
const icon = document.querySelector('#sidebar-toggle i');
|
||||
const label = document.querySelector('#sidebar-toggle .nav-label');
|
||||
if (icon) icon.className = 'fas fa-angles-right';
|
||||
if (label) label.textContent = 'Déplier';
|
||||
}
|
||||
});
|
||||
|
||||
async function fetchAPI(endpoint, options = {}) {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
try {
|
||||
const res = await fetch(location.origin + endpoint, { headers, ...options });
|
||||
if (!res.ok) return null;
|
||||
return await res.json();
|
||||
} catch (e) {
|
||||
console.error('fetchAPI error:', endpoint, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
if (window.dashboard && typeof window.dashboard.loadAllData === 'function') {
|
||||
await window.dashboard.loadAllData();
|
||||
}
|
||||
renderAllWidgets();
|
||||
updateSysMetrics();
|
||||
}
|
||||
|
||||
// ======================== STATE ========================
|
||||
let state = {
|
||||
gridCols: parseInt(localStorage.getItem('mc_gridCols')) || 3,
|
||||
widgetOrder: JSON.parse(localStorage.getItem('mc_widgetOrder') || 'null'),
|
||||
hiddenWidgets: JSON.parse(localStorage.getItem('mc_hiddenWidgets') || '[]'),
|
||||
focusOpen: false,
|
||||
drawerOpen: false,
|
||||
|
||||
// Expose getters that read from the live dashboard manager directly
|
||||
get hosts() { return window.dashboard ? window.dashboard.hosts || [] : []; },
|
||||
get containers() {
|
||||
// Try docker section first, then dashboard
|
||||
if (window.dockerSection && window.dockerSection.containers) return window.dockerSection.containers;
|
||||
if (window.dashboard) return window.dashboard.containers || [];
|
||||
return [];
|
||||
},
|
||||
get stats() {
|
||||
if (!window.dashboard) return {};
|
||||
const d = window.dashboard;
|
||||
const hosts = d.hosts || [];
|
||||
const online = hosts.filter(h => h.status === 'online').length;
|
||||
const tasks = d.taskLogs || d.tasks || [];
|
||||
const totalTasks = d.taskLogsStats ? d.taskLogsStats.total || 0 : tasks.length;
|
||||
const completed = d.taskLogsStats ? d.taskLogsStats.completed || 0 : 0;
|
||||
const failed = d.taskLogsStats ? d.taskLogsStats.failed || 0 : 0;
|
||||
const successRate = totalTasks > 0 ? Math.round((completed / totalTasks) * 100) : 100;
|
||||
const uptime = hosts.length > 0 ? Math.round((online / hosts.length) * 100) : 0;
|
||||
return {
|
||||
hostsOnline: online,
|
||||
hostsTotal: hosts.length,
|
||||
tasksExecuted: totalTasks,
|
||||
successRate: successRate,
|
||||
uptime: uptime
|
||||
};
|
||||
},
|
||||
get executions() {
|
||||
if (!window.dashboard) return [];
|
||||
// Map adhoc logs to the format expected by renderConsole
|
||||
const logs = window.dashboard.adhocWidgetLogs || window.dashboard.adhocHistory || [];
|
||||
return logs.slice(0, 10).map(l => ({
|
||||
cmd: l.command || l.playbook_name || l.name || '',
|
||||
host: l.target || l.host || '',
|
||||
status: (l.status === 'completed' || l.status === 'success') ? 'success' : (l.status === 'failed' ? 'error' : l.status || 'info'),
|
||||
type: l.source_type || 'Ad-hoc',
|
||||
ts: l.started_at ? new Date(l.started_at).getTime() : Date.now()
|
||||
}));
|
||||
},
|
||||
get schedules() {
|
||||
if (!window.dashboard) return [];
|
||||
return (window.dashboard.schedules || []).map(s => ({
|
||||
name: s.name || s.playbook_name || '',
|
||||
cron: s.cron_expression || s.cron || '',
|
||||
targets: s.target_hosts || s.targets || 'all',
|
||||
enabled: s.is_active !== false,
|
||||
next_run: s.next_run_at ? new Date(s.next_run_at).toLocaleString('fr-FR', { hour: '2-digit', minute: '2-digit', day: 'numeric', month: 'short' }) : '—',
|
||||
last_status: s.last_status || '—'
|
||||
}));
|
||||
},
|
||||
get liveEvents() {
|
||||
if (!window.dashboard) return [];
|
||||
// Build live events from recent task logs
|
||||
const logs = window.dashboard.taskLogs || [];
|
||||
return logs.slice(0, 12).map(l => ({
|
||||
message: l.playbook_name || l.name || l.command || 'Tâche',
|
||||
host: l.target || l.host || 'all',
|
||||
status: (l.status === 'completed' || l.status === 'success') ? 'success' : (l.status === 'failed' ? 'error' : (l.status === 'running' ? 'running' : 'info')),
|
||||
ts: l.started_at ? new Date(l.started_at).getTime() : (l.created_at ? new Date(l.created_at).getTime() : Date.now())
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// ======================== UTILITIES ========================
|
||||
function timeAgo(ts) {
|
||||
const s = Math.floor((Date.now() - ts) / 1000);
|
||||
if (s < 60) return `${s}s`;
|
||||
if (s < 3600) return `${Math.floor(s/60)}m`;
|
||||
if (s < 86400) return `${Math.floor(s/3600)}h`;
|
||||
return `${Math.floor(s/86400)}j`;
|
||||
}
|
||||
|
||||
function sparklineSVG(w=80, h=28, color='#22d3ee', points=20) {
|
||||
const data = Array.from({length:points}, () => Math.random());
|
||||
const max = Math.max(...data, 0.01);
|
||||
const step = w / (points - 1);
|
||||
const pts = data.map((v, i) => `${i*step},${h - (v/max)*(h-4)}`).join(' ');
|
||||
return `<svg class="sparkline" width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><polyline fill="none" stroke="${color}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" points="${pts}"/><polyline fill="url(#sg)" stroke="none" points="0,${h} ${pts} ${w},${h}"/><defs><linearGradient id="sg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${color}" stop-opacity=".15"/><stop offset="1" stop-color="${color}" stop-opacity="0"/></linearGradient></defs></svg>`;
|
||||
}
|
||||
|
||||
function progressColor(val) {
|
||||
if (val > 80) return 'bg-red-500';
|
||||
if (val > 60) return 'bg-yellow-500';
|
||||
return 'bg-cyan-400';
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const m = { success:'bg-emerald-500/20 text-emerald-400', error:'bg-red-500/20 text-red-400', warning:'bg-yellow-500/20 text-yellow-400', running:'bg-blue-500/20 text-blue-400', stopped:'bg-gray-500/20 text-gray-400', info:'bg-cyan-500/20 text-cyan-400' };
|
||||
return m[status] || m.info;
|
||||
}
|
||||
|
||||
function showToast(message, type='success', duration=3500) {
|
||||
const container = document.getElementById('toast-container');
|
||||
const icons = { success:'fa-check-circle text-emerald-400', error:'fa-times-circle text-red-400', warning:'fa-exclamation-triangle text-yellow-400', info:'fa-info-circle text-cyan-400' };
|
||||
const id = 'toast-' + Date.now();
|
||||
const el = document.createElement('div');
|
||||
el.className = 'toast relative';
|
||||
el.id = id;
|
||||
el.innerHTML = `<i class="fas ${icons[type]||icons.info} text-lg mt-0.5"></i><div class="flex-1"><div class="text-sm font-medium">${message}</div></div><button onclick="this.closest('.toast').remove()" class="text-gray-500 hover:text-white ml-2"><i class="fas fa-xmark"></i></button><div class="toast-progress ${type==='error'?'bg-red-500':type==='warning'?'bg-yellow-500':'bg-cyan-400'}" id="${id}-bar" style="width:100%"></div>`;
|
||||
container.appendChild(el);
|
||||
anime({ targets:`#${id}`, translateX:[80,0], opacity:[0,1], duration:400, easing:'easeOutCubic' });
|
||||
anime({ targets:`#${id}-bar`, width:'0%', duration:duration, easing:'linear', complete:()=>{ anime({ targets:`#${id}`, translateX:80, opacity:0, duration:300, easing:'easeInCubic', complete:()=>el.remove() }); } });
|
||||
}
|
||||
|
||||
function updateSysMetrics() {
|
||||
const h = state.hosts || [];
|
||||
const online = h.filter(x => x.status === 'online');
|
||||
const avgCpu = online.length ? Math.round(online.reduce((s,x)=>s+(x.cpu||0),0)/online.length) : 0;
|
||||
const avgRam = online.length ? Math.round(online.reduce((s,x)=>s+(x.ram||0),0)/online.length) : 0;
|
||||
document.getElementById('sys-cpu').textContent = avgCpu + '%';
|
||||
document.getElementById('sys-ram').textContent = avgRam + '%';
|
||||
document.getElementById('sys-net').textContent = online.length + '/' + h.length;
|
||||
}
|
||||
// ======================== WIDGET DEFINITIONS ========================
|
||||
const WIDGETS = {
|
||||
'stats-overview': { title:'Vue d\'ensemble', icon:'fa-chart-pie', color:'text-cyan-400', span:2, render:renderStatsWidget },
|
||||
'live-feed': { title:'Activité en direct', icon:'fa-bolt', color:'text-yellow-400', render:renderLiveFeed },
|
||||
'containers': { title:'Containers favoris', icon:'fa-docker fab', color:'text-blue-400', render:renderContainers },
|
||||
'hosts': { title:'Gestion des Hosts', icon:'fa-server', color:'text-emerald-400', span:2, render:renderHosts },
|
||||
'console': { title:'Console Ad-Hoc', icon:'fa-terminal', color:'text-violet-400', render:renderConsole },
|
||||
'scheduler': { title:'Planificateur', icon:'fa-calendar-alt', color:'text-pink-400', render:renderScheduler },
|
||||
'actions': { title:'Actions Rapides', icon:'fa-rocket', color:'text-orange-400', render:renderActions },
|
||||
};
|
||||
|
||||
const DEFAULT_ORDER = ['stats-overview','live-feed','containers','hosts','console','scheduler','actions'];
|
||||
|
||||
function getWidgetOrder() {
|
||||
return state.widgetOrder || DEFAULT_ORDER;
|
||||
}
|
||||
|
||||
function renderAllWidgets() {
|
||||
const grid = document.getElementById('widget-grid');
|
||||
grid.style.setProperty('--grid-cols', state.gridCols);
|
||||
grid.innerHTML = '';
|
||||
const order = getWidgetOrder().filter(id => !state.hiddenWidgets.includes(id));
|
||||
order.forEach((id, idx) => {
|
||||
const def = WIDGETS[id];
|
||||
if (!def) return;
|
||||
const w = document.createElement('div');
|
||||
w.className = `widget ${def.span ? 'span-2' : ''}`;
|
||||
w.id = `widget-${id}`;
|
||||
w.dataset.widgetId = id;
|
||||
w.draggable = true;
|
||||
w.innerHTML = `<div class="widget-header" ondblclick="openFocus('${id}')"><div class="flex items-center gap-3"><i class="fas ${def.icon} ${def.color}"></i><h3 class="font-heading font-semibold text-sm">${def.title}</h3></div><div class="flex items-center gap-2"><button onclick="openFocus('${id}')" class="text-gray-600 hover:text-gray-300 text-xs" title="Agrandir"><i class="fas fa-expand"></i></button></div></div><div class="widget-body" id="wb-${id}"></div>`;
|
||||
grid.appendChild(w);
|
||||
});
|
||||
// Render each widget
|
||||
order.filter(id => !state.hiddenWidgets.includes(id)).forEach(id => { WIDGETS[id]?.render(); });
|
||||
setupDragDrop();
|
||||
renderWidgetToggles();
|
||||
animateWidgets();
|
||||
}
|
||||
|
||||
function renderWidget(id) {
|
||||
WIDGETS[id]?.render();
|
||||
}
|
||||
|
||||
function animateWidgets() {
|
||||
anime({ targets:'.widget', translateY:[30,0], opacity:[0,1], delay:anime.stagger(60, {start:100}), duration:600, easing:'easeOutCubic' });
|
||||
}
|
||||
|
||||
// ======================== WIDGET RENDERERS ========================
|
||||
function renderStatsWidget() {
|
||||
const s = state.stats;
|
||||
const el = document.getElementById('wb-stats-overview');
|
||||
if (!el) return;
|
||||
el.innerHTML = `<div class="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div class="kpi-card"><div class="flex items-center justify-between mb-2"><i class="fas fa-server text-cyan-400 text-lg"></i>${sparklineSVG(64,24,'#22d3ee')}</div><div class="kpi-value text-cyan-400">${s.hostsOnline||0}</div><div class="kpi-label">Hosts en ligne</div></div>
|
||||
<div class="kpi-card"><div class="flex items-center justify-between mb-2"><i class="fas fa-list-check text-violet-400 text-lg"></i>${sparklineSVG(64,24,'#8b5cf6')}</div><div class="kpi-value text-violet-400">${s.tasksExecuted||0}</div><div class="kpi-label">Tâches exécutées</div></div>
|
||||
<div class="kpi-card"><div class="flex items-center justify-between mb-2"><i class="fas fa-bullseye text-emerald-400 text-lg"></i>${sparklineSVG(64,24,'#10b981')}</div><div class="kpi-value text-emerald-400">${s.successRate||0}%</div><div class="kpi-label">Taux de succès</div></div>
|
||||
<div class="kpi-card"><div class="flex items-center justify-between mb-2"><i class="fas fa-signal text-pink-400 text-lg"></i>${sparklineSVG(64,24,'#ec4899')}</div><div class="kpi-value text-pink-400">${s.uptime||0}%</div><div class="kpi-label">Disponibilité</div></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderLiveFeed() {
|
||||
const el = document.getElementById('wb-live-feed');
|
||||
if (!el) return;
|
||||
const events = state.liveEvents.slice(0, 12);
|
||||
el.innerHTML = `<div style="max-height:320px;overflow-y:auto">${events.length === 0 ? '<div class="text-center text-gray-600 py-8"><i class="fas fa-satellite-dish text-2xl mb-2"></i><p class="text-sm">En attente d\'événements...</p></div>' : events.map(ev => `<div class="feed-item"><div class="feed-time">${timeAgo(ev.ts)}</div><div class="flex-1"><div class="text-sm">${ev.message}</div><div class="text-xs text-gray-500 mt-0.5">${ev.host}</div></div><span class="feed-badge ${statusBadge(ev.status)}">${ev.status}</span></div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderContainers() {
|
||||
const el = document.getElementById('wb-containers');
|
||||
if (!el) return;
|
||||
const ctrs = (state.containers || []).slice(0, 6);
|
||||
el.innerHTML = `<div class="space-y-2">${ctrs.map(c => `<div class="flex items-center justify-between p-3 rounded-xl bg-white/[.02] border border-white/[.04] hover:border-white/10 transition-all group">
|
||||
<div class="flex items-center gap-3"><span class="status-dot ${c.status==='running'?'online':'offline'}"></span><div><div class="text-sm font-medium">${c.name}</div><div class="text-xs text-gray-500">${c.host||''} ${c.port?':'+c.port:''}</div></div></div>
|
||||
<div class="flex items-center gap-1.5 opacity-0 group-hover:opacity-100 transition-opacity">${c.status==='running'?`<button onclick="containerAction('${c.host_id||''}','${c.id}','restart')" class="w-7 h-7 rounded-lg bg-white/5 hover:bg-yellow-500/20 text-gray-400 hover:text-yellow-400 flex items-center justify-center text-xs" title="Redémarrer"><i class="fas fa-rotate"></i></button><button onclick="containerAction('${c.host_id||''}','${c.id}','stop')" class="w-7 h-7 rounded-lg bg-white/5 hover:bg-red-500/20 text-gray-400 hover:text-red-400 flex items-center justify-center text-xs" title="Arrêter"><i class="fas fa-stop"></i></button>`:`<button onclick="containerAction('${c.host_id||''}','${c.id}','start')" class="w-7 h-7 rounded-lg bg-white/5 hover:bg-emerald-500/20 text-gray-400 hover:text-emerald-400 flex items-center justify-center text-xs" title="Démarrer"><i class="fas fa-play"></i></button>`}</div>
|
||||
</div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderHosts() {
|
||||
const el = document.getElementById('wb-hosts');
|
||||
if (!el) return;
|
||||
const hosts = state.hosts.slice(0, 8);
|
||||
el.innerHTML = `<div class="overflow-x-auto"><table class="w-full text-sm"><thead><tr class="text-xs text-gray-500 border-b border-white/5"><th class="text-left pb-2 font-medium">Hôte</th><th class="text-left pb-2 font-medium">Statut</th><th class="text-left pb-2 font-medium">CPU</th><th class="text-left pb-2 font-medium">RAM</th><th class="text-left pb-2 font-medium">Disque</th><th class="text-left pb-2 font-medium">Uptime</th></tr></thead><tbody>${hosts.map(h => `<tr class="border-b border-white/[.03] hover:bg-white/[.02] transition-colors"><td class="py-2.5"><div class="flex items-center gap-2"><span class="status-dot ${h.status==='online'?'online':'offline'}"></span><span class="font-medium">${h.name}</span></div></td><td><span class="feed-badge ${statusBadge(h.status==='online'?'success':'error')}">${h.status}</span></td><td><div class="flex items-center gap-2"><div class="progress-bar w-16"><div class="progress-fill ${progressColor(h.cpu||0)}" style="width:${h.cpu||0}%"></div></div><span class="text-xs text-gray-400">${h.cpu||0}%</span></div></td><td><div class="flex items-center gap-2"><div class="progress-bar w-16"><div class="progress-fill ${progressColor(h.ram||0)}" style="width:${h.ram||0}%"></div></div><span class="text-xs text-gray-400">${h.ram||0}%</span></div></td><td><div class="flex items-center gap-2"><div class="progress-bar w-16"><div class="progress-fill ${progressColor(h.disk||0)}" style="width:${h.disk||0}%"></div></div><span class="text-xs text-gray-400">${h.disk||0}%</span></div></td><td class="text-gray-400">${h.uptime||'—'}</td></tr>`).join('')}</tbody></table></div>`;
|
||||
}
|
||||
|
||||
function renderConsole() {
|
||||
const el = document.getElementById('wb-console');
|
||||
if (!el) return;
|
||||
const execs = (state.executions || []).slice(0, 6);
|
||||
el.innerHTML = `<div class="space-y-2">${execs.map(e => `<div class="flex items-center justify-between p-2.5 rounded-lg bg-white/[.02] border border-white/[.04]"><div class="flex items-center gap-3"><span class="w-7 h-7 rounded-lg ${e.status==='success'?'bg-emerald-500/15 text-emerald-400':'bg-red-500/15 text-red-400'} flex items-center justify-center text-xs"><i class="fas ${e.status==='success'?'fa-check':'fa-xmark'}"></i></span><div><div class="text-sm font-mono">${e.cmd||e.command||''}</div><div class="text-xs text-gray-500">${e.host||e.target||''} · ${e.type||'Ad-hoc'}</div></div></div><span class="text-xs text-gray-500">${e.ago||timeAgo(e.ts||Date.now())}</span></div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderScheduler() {
|
||||
const el = document.getElementById('wb-scheduler');
|
||||
if (!el) return;
|
||||
const scheds = (state.schedules || []).slice(0, 5);
|
||||
el.innerHTML = `<div class="space-y-2">${scheds.map(s => `<div class="flex items-center justify-between p-3 rounded-xl bg-white/[.02] border border-white/[.04]"><div class="flex items-center gap-3"><span class="w-8 h-8 rounded-lg ${s.enabled?'bg-violet-500/15 text-violet-400':'bg-gray-500/15 text-gray-500'} flex items-center justify-center"><i class="fas ${s.enabled?'fa-clock':'fa-pause'}"></i></span><div><div class="text-sm font-medium">${s.name}</div><div class="text-xs text-gray-500">${s.cron||''} · ${s.targets||'all'}</div></div></div><div class="text-right"><div class="text-xs ${s.enabled?'text-cyan-400':'text-gray-600'}">${s.next_run||'—'}</div><span class="feed-badge mt-1 ${statusBadge(s.last_status||'info')}">${s.last_status||'—'}</span></div></div>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
function renderActions() {
|
||||
const el = document.getElementById('wb-actions');
|
||||
if (!el) return;
|
||||
const actions = [
|
||||
{ label:'Mise à jour', icon:'fa-download', color:'from-cyan-500 to-blue-500', action:'update' },
|
||||
{ label:'Redémarrer', icon:'fa-rotate', color:'from-yellow-500 to-orange-500', action:'restart' },
|
||||
{ label:'Backup', icon:'fa-database', color:'from-emerald-500 to-teal-500', action:'backup' },
|
||||
{ label:'Santé', icon:'fa-heart-pulse', color:'from-pink-500 to-rose-500', action:'health' },
|
||||
{ label:'Sync Hosts', icon:'fa-arrows-rotate', color:'from-violet-500 to-purple-500', action:'sync' },
|
||||
{ label:'Nettoyage', icon:'fa-broom', color:'from-amber-500 to-yellow-500', action:'cleanup' },
|
||||
];
|
||||
el.innerHTML = `<div class="grid grid-cols-2 gap-3">${actions.map(a => `<button onclick="triggerAction('${a.action}')" class="action-btn flex-col gap-2 py-4 hover:scale-[1.03] active:scale-100 group"><div class="w-10 h-10 rounded-xl bg-gradient-to-br ${a.color} flex items-center justify-center text-white shadow-lg group-hover:shadow-xl transition-shadow"><i class="fas ${a.icon}"></i></div><span class="text-xs">${a.label}</span></button>`).join('')}</div>`;
|
||||
}
|
||||
|
||||
async function containerAction(hostId, containerId, action) {
|
||||
showToast(`${action} du container en cours...`, 'info');
|
||||
const res = await fetchAPI(`/api/docker/containers/${hostId}/${containerId}/${action}`);
|
||||
if (res) { showToast(`Container ${action} effectué`, 'success'); await loadData(); }
|
||||
else showToast(`Échec: ${action}`, 'error');
|
||||
}
|
||||
|
||||
async function triggerAction(action) {
|
||||
const msgs = { update:'Mise à jour déclenchée', restart:'Redémarrage en cours', backup:'Backup lancé', health:'Vérification santé lancée', sync:'Synchronisation des hosts...', cleanup:'Nettoyage Docker lancé' };
|
||||
showToast(msgs[action] || 'Action exécutée', 'info');
|
||||
let endpoint = null;
|
||||
if (action === 'sync') endpoint = '/api/hosts/sync';
|
||||
if (action === 'health') endpoint = '/api/health/refresh';
|
||||
if (action === 'cleanup') endpoint = '/api/docker/collect-all';
|
||||
if (endpoint) {
|
||||
const res = await fetch(`${location.protocol}//${location.host}${endpoint}`, { method:'POST', headers:{ 'Content-Type':'application/json', ...(localStorage.getItem('accessToken')?{'Authorization':`Bearer ${localStorage.getItem('accessToken')}`}:{}) } }).catch(()=>null);
|
||||
if (res?.ok) showToast('Action terminée avec succès', 'success');
|
||||
else showToast('Action simulée (API indisponible)', 'warning');
|
||||
} else {
|
||||
setTimeout(() => showToast('Action simulée avec succès', 'success'), 1500);
|
||||
}
|
||||
}
|
||||
// ======================== DRAG & DROP ========================
|
||||
function setupDragDrop() {
|
||||
const widgets = document.querySelectorAll('.widget');
|
||||
let dragSrc = null;
|
||||
widgets.forEach(w => {
|
||||
w.addEventListener('dragstart', function(e) {
|
||||
dragSrc = this;
|
||||
this.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', this.dataset.widgetId);
|
||||
});
|
||||
w.addEventListener('dragend', function() { this.classList.remove('dragging'); });
|
||||
w.addEventListener('dragover', function(e) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; this.style.borderColor = 'rgba(139,92,246,.4)'; });
|
||||
w.addEventListener('dragleave', function() { this.style.borderColor = ''; });
|
||||
w.addEventListener('drop', function(e) {
|
||||
e.preventDefault();
|
||||
this.style.borderColor = '';
|
||||
if (dragSrc === this) return;
|
||||
const grid = document.getElementById('widget-grid');
|
||||
const allW = [...grid.children];
|
||||
const fromIdx = allW.indexOf(dragSrc);
|
||||
const toIdx = allW.indexOf(this);
|
||||
if (fromIdx < toIdx) this.after(dragSrc);
|
||||
else this.before(dragSrc);
|
||||
saveWidgetOrder();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function saveWidgetOrder() {
|
||||
const order = [...document.querySelectorAll('.widget')].map(w => w.dataset.widgetId);
|
||||
state.widgetOrder = order;
|
||||
localStorage.setItem('mc_widgetOrder', JSON.stringify(order));
|
||||
}
|
||||
|
||||
// ======================== GRID COLUMNS ========================
|
||||
function setGridCols(n) {
|
||||
state.gridCols = n;
|
||||
document.getElementById('widget-grid').style.setProperty('--grid-cols', n);
|
||||
document.querySelectorAll('.col-btn').forEach(b => b.classList.toggle('active', parseInt(b.dataset.cols) === n));
|
||||
localStorage.setItem('mc_gridCols', n);
|
||||
}
|
||||
|
||||
// ======================== FOCUS MODE ========================
|
||||
function openFocus(widgetId) {
|
||||
state.focusOpen = true;
|
||||
const def = WIDGETS[widgetId];
|
||||
if (!def) return;
|
||||
const el = document.getElementById('focus-content');
|
||||
el.innerHTML = `<div class="flex items-center justify-between mb-6"><div class="flex items-center gap-3"><i class="fas ${def.icon} ${def.color} text-xl"></i><h2 class="font-heading text-xl font-bold">${def.title}</h2></div><button onclick="closeFocus()" class="w-9 h-9 rounded-xl bg-white/5 hover:bg-white/10 flex items-center justify-center text-gray-400 hover:text-white transition-colors"><i class="fas fa-xmark"></i></button></div><div id="focus-body"></div>`;
|
||||
// Re-render with more data
|
||||
const focusBody = document.getElementById('focus-body');
|
||||
if (widgetId === 'hosts') {
|
||||
const hosts = state.hosts;
|
||||
focusBody.innerHTML = `<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">${hosts.map(h => `<div class="p-4 rounded-xl bg-white/[.03] border border-white/[.06]"><div class="flex items-center justify-between mb-3"><div class="flex items-center gap-2"><span class="status-dot ${h.status==='online'?'online':'offline'}"></span><span class="font-heading font-semibold">${h.name}</span></div><span class="text-xs text-gray-500">${h.os||''}</span></div><div class="space-y-2"><div class="flex items-center justify-between text-sm"><span class="text-gray-500">CPU</span><div class="flex items-center gap-2"><div class="progress-bar w-24"><div class="progress-fill ${progressColor(h.cpu||0)}" style="width:${h.cpu||0}%"></div></div><span>${h.cpu||0}%</span></div></div><div class="flex items-center justify-between text-sm"><span class="text-gray-500">RAM</span><div class="flex items-center gap-2"><div class="progress-bar w-24"><div class="progress-fill ${progressColor(h.ram||0)}" style="width:${h.ram||0}%"></div></div><span>${h.ram||0}%</span></div></div><div class="flex items-center justify-between text-sm"><span class="text-gray-500">Disque</span><div class="flex items-center gap-2"><div class="progress-bar w-24"><div class="progress-fill ${progressColor(h.disk||0)}" style="width:${h.disk||0}%"></div></div><span>${h.disk||0}%</span></div></div><div class="text-xs text-gray-500 mt-2">Uptime: ${h.uptime||'—'}</div></div></div>`).join('')}</div>`;
|
||||
} else if (widgetId === 'stats-overview') {
|
||||
focusBody.innerHTML = document.getElementById('wb-stats-overview')?.innerHTML || '';
|
||||
focusBody.innerHTML += `<div class="mt-6 grid grid-cols-2 gap-4">${state.hosts.slice(0,4).map(h=>`<div class="p-4 rounded-xl bg-white/[.03] border border-white/[.06]"><div class="font-heading font-semibold mb-2">${h.name}</div><div class="flex gap-4">${sparklineSVG(120,40,'#22d3ee',30)} ${sparklineSVG(120,40,'#8b5cf6',30)}</div><div class="flex gap-4 mt-1 text-xs text-gray-500"><span>CPU 24h</span><span>RAM 24h</span></div></div>`).join('')}</div>`;
|
||||
} else {
|
||||
focusBody.innerHTML = document.getElementById(`wb-${widgetId}`)?.innerHTML || '<p class="text-gray-500">Contenu détaillé non disponible</p>';
|
||||
}
|
||||
document.getElementById('focus-overlay').classList.add('open');
|
||||
anime({ targets:'#focus-content', scale:[.9,1], opacity:[0,1], duration:300, easing:'easeOutCubic' });
|
||||
}
|
||||
function closeFocus() {
|
||||
state.focusOpen = false;
|
||||
anime({ targets:'#focus-content', scale:.9, opacity:0, duration:200, easing:'easeInCubic', complete:()=>document.getElementById('focus-overlay').classList.remove('open') });
|
||||
}
|
||||
|
||||
// ======================== CUSTOMIZE DRAWER ========================
|
||||
function openCustomizeDrawer() {
|
||||
state.drawerOpen = true;
|
||||
document.getElementById('customize-drawer').classList.add('open');
|
||||
document.getElementById('drawer-overlay').classList.add('open');
|
||||
anime({ targets:'#customize-drawer', translateX:[360,0], duration:350, easing:'easeOutCubic' });
|
||||
}
|
||||
function closeCustomizeDrawer() {
|
||||
state.drawerOpen = false;
|
||||
anime({ targets:'#customize-drawer', translateX:360, duration:250, easing:'easeInCubic', complete:()=>{ document.getElementById('customize-drawer').classList.remove('open'); document.getElementById('drawer-overlay').classList.remove('open'); } });
|
||||
}
|
||||
|
||||
function renderWidgetToggles() {
|
||||
const el = document.getElementById('widget-toggles');
|
||||
if (!el) return;
|
||||
el.innerHTML = Object.entries(WIDGETS).map(([id, def]) => {
|
||||
const checked = !state.hiddenWidgets.includes(id);
|
||||
return `<label class="flex items-center justify-between p-3 rounded-xl hover:bg-white/[.03] cursor-pointer transition-colors mb-1"><div class="flex items-center gap-3"><i class="fas ${def.icon} ${def.color}"></i><span class="text-sm font-medium">${def.title}</span></div><input type="checkbox" ${checked?'checked':''} onchange="toggleWidget('${id}',this.checked)" class="w-4 h-4 accent-violet-500 rounded"></label>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function toggleWidget(id, show) {
|
||||
if (show) state.hiddenWidgets = state.hiddenWidgets.filter(w => w !== id);
|
||||
else if (!state.hiddenWidgets.includes(id)) state.hiddenWidgets.push(id);
|
||||
localStorage.setItem('mc_hiddenWidgets', JSON.stringify(state.hiddenWidgets));
|
||||
renderAllWidgets();
|
||||
}
|
||||
|
||||
function resetLayout() {
|
||||
state.widgetOrder = null;
|
||||
state.hiddenWidgets = [];
|
||||
state.gridCols = 3;
|
||||
localStorage.removeItem('mc_widgetOrder');
|
||||
localStorage.removeItem('mc_hiddenWidgets');
|
||||
localStorage.setItem('mc_gridCols', '3');
|
||||
document.querySelectorAll('.col-btn').forEach(b => b.classList.toggle('active', parseInt(b.dataset.cols) === 3));
|
||||
renderAllWidgets();
|
||||
showToast('Disposition réinitialisée', 'success');
|
||||
closeCustomizeDrawer();
|
||||
}
|
||||
|
||||
@ -101,6 +101,14 @@ def create_app() -> FastAPI:
|
||||
headers={"Service-Worker-Allowed": "/"},
|
||||
)
|
||||
|
||||
@app.get("/dashboard.html", response_class=HTMLResponse)
|
||||
async def serve_dashboard():
|
||||
"""Serve le nouveau dashboard Mission Control Pro."""
|
||||
dashboard_path = settings.base_dir / "dashboard.html"
|
||||
if dashboard_path.exists():
|
||||
return dashboard_path.read_text(encoding='utf-8')
|
||||
return HTMLResponse(content="Fichier dashboard.html non trouvé", status_code=404)
|
||||
|
||||
@app.get("/api", response_class=HTMLResponse)
|
||||
async def api_home():
|
||||
"""Page d'accueil de l'API."""
|
||||
|
||||
2164
app/index.html
2164
app/index.html
File diff suppressed because it is too large
Load Diff
6128
app/index.html.bak
Normal file
6128
app/index.html.bak
Normal file
File diff suppressed because it is too large
Load Diff
18270
app/main.js
18270
app/main.js
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
"""Database configuration and session management for Homelab Automation.
|
||||
Uses SQLAlchemy 2.x async engine with SQLite + aiosqlite driver.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
@ -173,11 +172,20 @@ async def init_db() -> None:
|
||||
import sqlalchemy as sa
|
||||
sync_engine = sa.create_engine(sync_url)
|
||||
with sync_engine.connect() as conn:
|
||||
# Case 1: Table doesn't exist
|
||||
conn.execute(sa.text(
|
||||
"CREATE TABLE IF NOT EXISTS alembic_version "
|
||||
"(version_num VARCHAR(255) NOT NULL, "
|
||||
"PRIMARY KEY (version_num))"
|
||||
))
|
||||
# Case 2: Table exists but column might be too short (VARCHAR(32))
|
||||
# Descriptive revisions like '0011_add_docker_management_tables' are > 30 chars
|
||||
try:
|
||||
conn.execute(sa.text(
|
||||
"ALTER TABLE alembic_version MODIFY version_num VARCHAR(255) NOT NULL"
|
||||
))
|
||||
except Exception:
|
||||
pass # Might fail if not MySQL or column already long (SQLite has no MODIFY)
|
||||
conn.commit()
|
||||
sync_engine.dispose()
|
||||
print("[DB] MySQL: alembic_version table ensured with VARCHAR(255)")
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Docker Alert model for Homelab Automation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Docker Container model for Homelab Automation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Docker Image model for Homelab Automation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"""Docker Volume model for Homelab Automation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from __future__ import annotations
|
||||
"""
|
||||
Model for storing ansible-lint results per playbook.
|
||||
"""
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -6,7 +6,6 @@ Security notes:
|
||||
- Commands are masked to remove sensitive values before storage
|
||||
- Never stores raw commands that were blocked
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
"""
|
||||
Model for terminal sessions - stores SSH terminal session metadata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
Designed for single-user now but prepared for multi-user with roles in the future.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
@ -30,34 +30,45 @@ async def create_alert(
|
||||
db_session: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""Crée une nouvelle alerte."""
|
||||
repo = AlertRepository(db_session)
|
||||
alert = await repo.create(
|
||||
category=alert_data.category,
|
||||
level=alert_data.level,
|
||||
title=alert_data.title,
|
||||
message=alert_data.message,
|
||||
source=alert_data.source,
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
await ws_manager.broadcast({
|
||||
"type": "alert_created",
|
||||
"data": {
|
||||
try:
|
||||
repo = AlertRepository(db_session)
|
||||
alert = await repo.create(
|
||||
category=alert_data.category,
|
||||
level=alert_data.level,
|
||||
title=alert_data.title,
|
||||
message=alert_data.message,
|
||||
source=alert_data.source,
|
||||
)
|
||||
|
||||
await db_session.refresh(alert)
|
||||
|
||||
# Access attributes before commit to avoid MissingGreenlet on async lazy loading
|
||||
alert_dict = {
|
||||
"id": alert.id,
|
||||
"title": alert.title,
|
||||
"message": alert.message,
|
||||
"level": alert.level,
|
||||
"category": alert.category,
|
||||
"created_at": alert.created_at,
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
"id": alert.id,
|
||||
"title": alert.title,
|
||||
"message": alert.message,
|
||||
"level": alert.level,
|
||||
"category": alert.category,
|
||||
"created_at": alert.created_at,
|
||||
}
|
||||
|
||||
await db_session.commit()
|
||||
|
||||
await ws_manager.broadcast({
|
||||
"type": "alert_created",
|
||||
"data": {
|
||||
"id": alert_dict["id"],
|
||||
"title": alert_dict["title"],
|
||||
"message": alert_dict["message"],
|
||||
"level": alert_dict["level"],
|
||||
}
|
||||
})
|
||||
|
||||
return alert_dict
|
||||
except Exception as e:
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("")
|
||||
|
||||
@ -624,7 +624,6 @@ async def create_terminal_session(
|
||||
|
||||
if existing_session:
|
||||
await session_repo.update_last_seen(existing_session.id)
|
||||
await db_session.commit()
|
||||
|
||||
terminal_service.record_session_created(reused=True)
|
||||
logger.info(f"session_reused session={existing_session.id[:8]}... user={username} host={host.name}")
|
||||
|
||||
BIN
data/homelab.db
Normal file
BIN
data/homelab.db
Normal file
Binary file not shown.
38
debug_alerts.py
Normal file
38
debug_alerts.py
Normal file
@ -0,0 +1,38 @@
|
||||
import os
|
||||
import asyncio
|
||||
from sqlalchemy import text
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from app.models.database import async_session_maker, engine
|
||||
|
||||
async def check():
|
||||
async with async_session_maker() as session:
|
||||
try:
|
||||
print(f"DATABASE URL: {engine.url}")
|
||||
# Check table existence and columns
|
||||
print("Checking alerts table...")
|
||||
res = await session.execute(text("DESCRIBE alerts"))
|
||||
columns = res.fetchall()
|
||||
for col in columns:
|
||||
print(f"Column: {col}")
|
||||
|
||||
# Try a dry insertion
|
||||
print("\nAttempting dry insertion...")
|
||||
# Use columns found or assume standard ones
|
||||
stmt = text("INSERT INTO alerts (category, title, level, message, source, created_at) VALUES ('test', 'Test', 'info', 'Test', 'script', NOW())")
|
||||
res = await session.execute(stmt)
|
||||
print(f"Insert success, rowcount: {res.rowcount}")
|
||||
await session.commit()
|
||||
print("Commit success")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nERROR: {type(e).__name__}: {str(e)}")
|
||||
if hasattr(e, 'orig'):
|
||||
print(f"Original error: {e.orig}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.run_until_complete(check())
|
||||
# Don't close explicitly, or wait a bit
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,58 @@
|
||||
# ✅ Vérification de santé
|
||||
|
||||
## Informations
|
||||
|
||||
| Propriété | Valeur |
|
||||
|-----------|--------|
|
||||
| **ID** | `64ab11d0c9b845a5931d685fff03b46b` |
|
||||
| **Nom** | Vérification de santé |
|
||||
| **Cible** | `hp2.i7.home` |
|
||||
| **Statut** | completed |
|
||||
| **Type** | Manuel |
|
||||
| **Progression** | 100% |
|
||||
| **Début** | 2026-03-05T20:08:24.766043+00:00 |
|
||||
| **Fin** | 2026-03-05T20:08:29.280543+00:00 |
|
||||
| **Durée** | 4.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: [hp2.i7.home] => {"changed": false, "ping": "pong"}
|
||||
|
||||
TASK [Gather minimal facts] ****************************************************
|
||||
ok: [hp2.i7.home]
|
||||
|
||||
TASK [Get system uptime] *******************************************************
|
||||
ok: [hp2.i7.home] => {"changed": false, "cmd": ["uptime"], "delta": "0:00:00.003591", "end": "2026-03-05 15:08:28.105850", "msg": "", "rc": 0, "start": "2026-03-05 15:08:28.102259", "stderr": "", "stderr_lines": [], "stdout": " 15:08:28 up 70 days, 3:25, 1 user, load average: 1.07, 1.05, 1.06", "stdout_lines": [" 15:08:28 up 70 days, 3:25, 1 user, load average: 1.07, 1.05, 1.06"]}
|
||||
|
||||
TASK [Get disk usage] **********************************************************
|
||||
ok: [hp2.i7.home] => {"changed": false, "cmd": "df -h / | tail -1 | awk '{print $5}'", "delta": "0:00:00.005100", "end": "2026-03-05 15:08:28.476038", "msg": "", "rc": 0, "start": "2026-03-05 15:08:28.470938", "stderr": "", "stderr_lines": [], "stdout": "15%", "stdout_lines": ["15%"]}
|
||||
|
||||
TASK [Get memory usage (Linux)] ************************************************
|
||||
ok: [hp2.i7.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.005330", "end": "2026-03-05 15:08:28.867007", "msg": "", "rc": 0, "start": "2026-03-05 15:08:28.861677", "stderr": "", "stderr_lines": [], "stdout": "79.3%", "stdout_lines": ["79.3%"]}
|
||||
|
||||
TASK [Get CPU temperature (ARM/SBC)] *******************************************
|
||||
ok: [hp2.i7.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.005991", "end": "2026-03-05 15:08:29.240664", "msg": "", "rc": 0, "start": "2026-03-05 15:08:29.234673", "stderr": "cat: /sys/class/thermal/thermal_zone0/temp: No data available", "stderr_lines": ["cat: /sys/class/thermal/thermal_zone0/temp: No data available"], "stdout": "0.0°C", "stdout_lines": ["0.0°C"]}
|
||||
|
||||
TASK [Get CPU load] ************************************************************
|
||||
ok: [hp2.i7.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.004682", "end": "2026-03-05 15:08:29.597432", "msg": "", "rc": 0, "start": "2026-03-05 15:08:29.592750", "stderr": "", "stderr_lines": [], "stdout": "1.07", "stdout_lines": ["1.07"]}
|
||||
|
||||
TASK [Display health status] ***************************************************
|
||||
ok: [hp2.i7.home] => {
|
||||
"msg": "═══════════════════════════════════════\nHost: hp2.i7.home\nStatus: OK\n═══════════════════════════════════════\nUptime: 15:08:28 up 70 days, 3:25, 1 user, load average: 1.07, 1.05, 1.06\nDisk Usage: 15%\nMemory Usage: 79.3%\nCPU Load: 1.07\nCPU Temp: 0.0°C\n═══════════════════════════════════════\n"
|
||||
}
|
||||
|
||||
PLAY RECAP *********************************************************************
|
||||
hp2.i7.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-05T20:08:29.316172+00:00*
|
||||
@ -0,0 +1,58 @@
|
||||
# ✅ Vérification de santé
|
||||
|
||||
## Informations
|
||||
|
||||
| Propriété | Valeur |
|
||||
|-----------|--------|
|
||||
| **ID** | `cfe04d1f88664f4180b6e843fb8903c2` |
|
||||
| **Nom** | Vérification de santé |
|
||||
| **Cible** | `jump.point.home` |
|
||||
| **Statut** | completed |
|
||||
| **Type** | Manuel |
|
||||
| **Progression** | 100% |
|
||||
| **Début** | 2026-03-05T20:09:01.401458+00:00 |
|
||||
| **Fin** | 2026-03-05T20:09:06.027820+00:00 |
|
||||
| **Durée** | 4.6s |
|
||||
|
||||
## 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: [jump.point.home] => {"changed": false, "ping": "pong"}
|
||||
|
||||
TASK [Gather minimal facts] ****************************************************
|
||||
ok: [jump.point.home]
|
||||
|
||||
TASK [Get system uptime] *******************************************************
|
||||
ok: [jump.point.home] => {"changed": false, "cmd": ["uptime"], "delta": "0:00:00.007365", "end": "2026-03-05 15:09:04.864969", "msg": "", "rc": 0, "start": "2026-03-05 15:09:04.857604", "stderr": "", "stderr_lines": [], "stdout": " 15:09:04 up 37 days, 5:55, 2 users, load average: 0.05, 0.41, 0.50", "stdout_lines": [" 15:09:04 up 37 days, 5:55, 2 users, load average: 0.05, 0.41, 0.50"]}
|
||||
|
||||
TASK [Get disk usage] **********************************************************
|
||||
ok: [jump.point.home] => {"changed": false, "cmd": "df -h / | tail -1 | awk '{print $5}'", "delta": "0:00:00.005618", "end": "2026-03-05 15:09:05.261897", "msg": "", "rc": 0, "start": "2026-03-05 15:09:05.256279", "stderr": "", "stderr_lines": [], "stdout": "89%", "stdout_lines": ["89%"]}
|
||||
|
||||
TASK [Get memory usage (Linux)] ************************************************
|
||||
ok: [jump.point.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.006593", "end": "2026-03-05 15:09:05.685701", "msg": "", "rc": 0, "start": "2026-03-05 15:09:05.679108", "stderr": "", "stderr_lines": [], "stdout": "43.6%", "stdout_lines": ["43.6%"]}
|
||||
|
||||
TASK [Get CPU temperature (ARM/SBC)] *******************************************
|
||||
ok: [jump.point.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.003109", "end": "2026-03-05 15:09:06.112325", "msg": "", "rc": 0, "start": "2026-03-05 15:09:06.109216", "stderr": "", "stderr_lines": [], "stdout": "N/A", "stdout_lines": ["N/A"]}
|
||||
|
||||
TASK [Get CPU load] ************************************************************
|
||||
ok: [jump.point.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.005663", "end": "2026-03-05 15:09:06.539325", "msg": "", "rc": 0, "start": "2026-03-05 15:09:06.533662", "stderr": "", "stderr_lines": [], "stdout": "0.21", "stdout_lines": ["0.21"]}
|
||||
|
||||
TASK [Display health status] ***************************************************
|
||||
ok: [jump.point.home] => {
|
||||
"msg": "═══════════════════════════════════════\nHost: jump.point.home\nStatus: OK\n═══════════════════════════════════════\nUptime: 15:09:04 up 37 days, 5:55, 2 users, load average: 0.05, 0.41, 0.50\nDisk Usage: 89%\nMemory Usage: 43.6%\nCPU Load: 0.21\nCPU Temp: N/A\n═══════════════════════════════════════\n"
|
||||
}
|
||||
|
||||
PLAY RECAP *********************************************************************
|
||||
jump.point.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-05T20:09:06.052758+00:00*
|
||||
@ -0,0 +1,58 @@
|
||||
# ✅ Vérification de santé
|
||||
|
||||
## Informations
|
||||
|
||||
| Propriété | Valeur |
|
||||
|-----------|--------|
|
||||
| **ID** | `b215dc1180fd4d4a9b58092068b94204` |
|
||||
| **Nom** | Vérification de santé |
|
||||
| **Cible** | `jump.point.home` |
|
||||
| **Statut** | completed |
|
||||
| **Type** | Manuel |
|
||||
| **Progression** | 100% |
|
||||
| **Début** | 2026-03-05T20:10:32.275481+00:00 |
|
||||
| **Fin** | 2026-03-05T20:10:37.121800+00:00 |
|
||||
| **Durée** | 4.8s |
|
||||
|
||||
## 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: [jump.point.home] => {"changed": false, "ping": "pong"}
|
||||
|
||||
TASK [Gather minimal facts] ****************************************************
|
||||
ok: [jump.point.home]
|
||||
|
||||
TASK [Get system uptime] *******************************************************
|
||||
ok: [jump.point.home] => {"changed": false, "cmd": ["uptime"], "delta": "0:00:00.003997", "end": "2026-03-05 15:10:35.923491", "msg": "", "rc": 0, "start": "2026-03-05 15:10:35.919494", "stderr": "", "stderr_lines": [], "stdout": " 15:10:35 up 37 days, 5:56, 2 users, load average: 0.64, 0.53, 0.54", "stdout_lines": [" 15:10:35 up 37 days, 5:56, 2 users, load average: 0.64, 0.53, 0.54"]}
|
||||
|
||||
TASK [Get disk usage] **********************************************************
|
||||
ok: [jump.point.home] => {"changed": false, "cmd": "df -h / | tail -1 | awk '{print $5}'", "delta": "0:00:00.005599", "end": "2026-03-05 15:10:36.334270", "msg": "", "rc": 0, "start": "2026-03-05 15:10:36.328671", "stderr": "", "stderr_lines": [], "stdout": "89%", "stdout_lines": ["89%"]}
|
||||
|
||||
TASK [Get memory usage (Linux)] ************************************************
|
||||
ok: [jump.point.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.005740", "end": "2026-03-05 15:10:36.736818", "msg": "", "rc": 0, "start": "2026-03-05 15:10:36.731078", "stderr": "", "stderr_lines": [], "stdout": "43.8%", "stdout_lines": ["43.8%"]}
|
||||
|
||||
TASK [Get CPU temperature (ARM/SBC)] *******************************************
|
||||
ok: [jump.point.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.003495", "end": "2026-03-05 15:10:37.139141", "msg": "", "rc": 0, "start": "2026-03-05 15:10:37.135646", "stderr": "", "stderr_lines": [], "stdout": "N/A", "stdout_lines": ["N/A"]}
|
||||
|
||||
TASK [Get CPU load] ************************************************************
|
||||
ok: [jump.point.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.005633", "end": "2026-03-05 15:10:37.541890", "msg": "", "rc": 0, "start": "2026-03-05 15:10:37.536257", "stderr": "", "stderr_lines": [], "stdout": "0.58", "stdout_lines": ["0.58"]}
|
||||
|
||||
TASK [Display health status] ***************************************************
|
||||
ok: [jump.point.home] => {
|
||||
"msg": "═══════════════════════════════════════\nHost: jump.point.home\nStatus: OK\n═══════════════════════════════════════\nUptime: 15:10:35 up 37 days, 5:56, 2 users, load average: 0.64, 0.53, 0.54\nDisk Usage: 89%\nMemory Usage: 43.8%\nCPU Load: 0.58\nCPU Temp: N/A\n═══════════════════════════════════════\n"
|
||||
}
|
||||
|
||||
PLAY RECAP *********************************************************************
|
||||
jump.point.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-05T20:10:37.157542+00:00*
|
||||
@ -0,0 +1,58 @@
|
||||
# ✅ Vérification de santé
|
||||
|
||||
## Informations
|
||||
|
||||
| Propriété | Valeur |
|
||||
|-----------|--------|
|
||||
| **ID** | `339d9caa768a4303970c8a8e91ad81f7` |
|
||||
| **Nom** | Vérification de santé |
|
||||
| **Cible** | `jump.point.home` |
|
||||
| **Statut** | completed |
|
||||
| **Type** | Manuel |
|
||||
| **Progression** | 100% |
|
||||
| **Début** | 2026-03-05T20:11:28.307152+00:00 |
|
||||
| **Fin** | 2026-03-05T20:11:32.561895+00:00 |
|
||||
| **Durée** | 4.3s |
|
||||
|
||||
## 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: [jump.point.home] => {"changed": false, "ping": "pong"}
|
||||
|
||||
TASK [Gather minimal facts] ****************************************************
|
||||
ok: [jump.point.home]
|
||||
|
||||
TASK [Get system uptime] *******************************************************
|
||||
ok: [jump.point.home] => {"changed": false, "cmd": ["uptime"], "delta": "0:00:00.003988", "end": "2026-03-05 15:11:31.056671", "msg": "", "rc": 0, "start": "2026-03-05 15:11:31.052683", "stderr": "", "stderr_lines": [], "stdout": " 15:11:31 up 37 days, 5:57, 2 users, load average: 0.63, 0.55, 0.54", "stdout_lines": [" 15:11:31 up 37 days, 5:57, 2 users, load average: 0.63, 0.55, 0.54"]}
|
||||
|
||||
TASK [Get disk usage] **********************************************************
|
||||
ok: [jump.point.home] => {"changed": false, "cmd": "df -h / | tail -1 | awk '{print $5}'", "delta": "0:00:00.005665", "end": "2026-03-05 15:11:31.458475", "msg": "", "rc": 0, "start": "2026-03-05 15:11:31.452810", "stderr": "", "stderr_lines": [], "stdout": "89%", "stdout_lines": ["89%"]}
|
||||
|
||||
TASK [Get memory usage (Linux)] ************************************************
|
||||
ok: [jump.point.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.005694", "end": "2026-03-05 15:11:31.875636", "msg": "", "rc": 0, "start": "2026-03-05 15:11:31.869942", "stderr": "", "stderr_lines": [], "stdout": "43.7%", "stdout_lines": ["43.7%"]}
|
||||
|
||||
TASK [Get CPU temperature (ARM/SBC)] *******************************************
|
||||
ok: [jump.point.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.003221", "end": "2026-03-05 15:11:32.275082", "msg": "", "rc": 0, "start": "2026-03-05 15:11:32.271861", "stderr": "", "stderr_lines": [], "stdout": "N/A", "stdout_lines": ["N/A"]}
|
||||
|
||||
TASK [Get CPU load] ************************************************************
|
||||
ok: [jump.point.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.005658", "end": "2026-03-05 15:11:32.692291", "msg": "", "rc": 0, "start": "2026-03-05 15:11:32.686633", "stderr": "", "stderr_lines": [], "stdout": "0.90", "stdout_lines": ["0.90"]}
|
||||
|
||||
TASK [Display health status] ***************************************************
|
||||
ok: [jump.point.home] => {
|
||||
"msg": "═══════════════════════════════════════\nHost: jump.point.home\nStatus: OK\n═══════════════════════════════════════\nUptime: 15:11:31 up 37 days, 5:57, 2 users, load average: 0.63, 0.55, 0.54\nDisk Usage: 89%\nMemory Usage: 43.7%\nCPU Load: 0.90\nCPU Temp: N/A\n═══════════════════════════════════════\n"
|
||||
}
|
||||
|
||||
PLAY RECAP *********************************************************************
|
||||
jump.point.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-05T20:11:32.600421+00:00*
|
||||
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user