homelab_automation/app/dashboard.html
Bruno Charest 817f8b4ee7
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
feat: Implement Homelab Automation API v2, introducing a new dashboard, comprehensive backend models, and API routes.
2026-03-06 09:31:08 -05:00

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>