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
785 lines
54 KiB
HTML
785 lines
54 KiB
HTML
<!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> |