feat: PercoHub v1 — dashboard centralisé homelab
This commit is contained in:
+258
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* PercoHub — Frontend JS
|
||||
* Charge les services depuis api.php, rend les catégories/cards, auto-refresh.
|
||||
*/
|
||||
|
||||
// ── Config ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const REFRESH_INTERVAL = 30; // secondes
|
||||
const API_BASE = '/api.php';
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let refreshTimer = null;
|
||||
let countdown = REFRESH_INTERVAL;
|
||||
let activeFilter = 'all';
|
||||
let lastData = null;
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
loadAll();
|
||||
startCountdown();
|
||||
});
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function loadAll(forceShow = false) {
|
||||
if (forceShow) {
|
||||
showLoader();
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}?action=all`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
lastData = data;
|
||||
|
||||
renderDashboard(data);
|
||||
renderGlobalStats(data);
|
||||
renderFilterBar(data);
|
||||
hideLoader();
|
||||
resetCountdown();
|
||||
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function renderDashboard(data) {
|
||||
const dash = document.getElementById('dashboard');
|
||||
dash.innerHTML = '';
|
||||
|
||||
data.categories.forEach(cat => {
|
||||
if (activeFilter !== 'all' && cat.id !== activeFilter) return;
|
||||
|
||||
const section = document.createElement('section');
|
||||
section.dataset.cat = cat.id;
|
||||
|
||||
// Header
|
||||
const onlineCount = cat.services.filter(s => s.status?.online).length;
|
||||
section.innerHTML = `
|
||||
<div class="section-header">
|
||||
<span class="text-xl">${cat.icon}</span>
|
||||
<span class="section-title">${cat.name}</span>
|
||||
<span class="section-count">${cat.services.length} services</span>
|
||||
<span class="text-xs text-gray-600 ml-auto">${onlineCount}/${cat.services.length} en ligne</span>
|
||||
</div>
|
||||
<div class="services-grid" id="grid-${cat.id}"></div>
|
||||
`;
|
||||
|
||||
dash.appendChild(section);
|
||||
|
||||
const grid = section.querySelector(`#grid-${cat.id}`);
|
||||
cat.services.forEach(svc => {
|
||||
grid.appendChild(buildCard(svc, cat.color));
|
||||
});
|
||||
});
|
||||
|
||||
dash.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function buildCard(svc, catColor) {
|
||||
const status = svc.status || {};
|
||||
const isOnline = status.online;
|
||||
const statusText = status.status_text || 'inconnu';
|
||||
const hasUrl = !!svc.url;
|
||||
const hasNote = !!svc.note;
|
||||
|
||||
// Status dot class
|
||||
let dotClass = 'loading';
|
||||
let badgeClass = 'badge-gray';
|
||||
let badgeText = statusText;
|
||||
|
||||
if (statusText === 'running' || statusText === 'online') {
|
||||
dotClass = 'online'; badgeClass = 'badge-green'; badgeText = 'running';
|
||||
} else if (statusText === 'healthy') {
|
||||
dotClass = 'online'; badgeClass = 'badge-green'; badgeText = 'healthy';
|
||||
} else if (statusText === 'offline' || statusText === 'absent' || statusText === 'exited') {
|
||||
dotClass = 'offline'; badgeClass = 'badge-red';
|
||||
} else if (statusText === 'restarting') {
|
||||
dotClass = 'warn'; badgeClass = 'badge-yellow';
|
||||
} else if (statusText === 'inconnu') {
|
||||
dotClass = 'loading'; badgeClass = 'badge-gray';
|
||||
}
|
||||
|
||||
// Response time badge
|
||||
let timeBadge = '';
|
||||
if (status.http?.ms && status.http.ms > 0) {
|
||||
timeBadge = `<span class="badge badge-blue">${status.http.ms}ms</span>`;
|
||||
}
|
||||
|
||||
// Docker badge
|
||||
let dockerBadge = '';
|
||||
if (svc.docker) {
|
||||
dockerBadge = `<span class="badge badge-gray" title="Container: ${svc.docker}">🐳</span>`;
|
||||
}
|
||||
|
||||
// Note
|
||||
let noteBadge = hasNote ? `<span class="note-tag" title="${svc.note}">${svc.note}</span>` : '';
|
||||
|
||||
// Tags
|
||||
const tagsHtml = (svc.tags || []).map(t =>
|
||||
`<span class="text-xs text-gray-600 mr-1">#${t}</span>`
|
||||
).join('');
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = `service-card cat-${catColor} ${hasUrl ? 'has-link' : ''}`;
|
||||
|
||||
if (hasUrl) {
|
||||
card.setAttribute('onclick', `openService('${svc.url}', '${svc.name}')`);
|
||||
card.setAttribute('title', `Ouvrir ${svc.name}`);
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="flex items-start justify-between gap-2 mb-2">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="status-dot ${dotClass}" title="${statusText}"></span>
|
||||
<span class="font-semibold text-sm text-white truncate">${svc.name}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 flex-shrink-0">
|
||||
${dockerBadge}
|
||||
<span class="badge ${badgeClass}">${badgeText}</span>
|
||||
${timeBadge}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mb-2 leading-relaxed">${svc.desc}</p>
|
||||
<div class="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div class="flex flex-wrap gap-1">${tagsHtml}</div>
|
||||
${noteBadge}
|
||||
${hasUrl ? `<span class="text-xs text-gray-700 truncate max-w-[140px]" title="${svc.url}">${formatUrl(svc.url)}</span>` : ''}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
// ── Global stats ───────────────────────────────────────────────────────────
|
||||
|
||||
function renderGlobalStats(data) {
|
||||
const el = document.getElementById('global-stats');
|
||||
const total = data.total || 0;
|
||||
const online = data.online || 0;
|
||||
const offline = total - online;
|
||||
|
||||
el.innerHTML = `
|
||||
<span class="stat-pill border-emerald-800 text-emerald-400 bg-emerald-900/20">
|
||||
<span class="status-dot online"></span> ${online}
|
||||
</span>
|
||||
<span class="stat-pill border-red-800 text-red-400 bg-red-900/20">
|
||||
<span class="status-dot offline"></span> ${offline}
|
||||
</span>
|
||||
<span class="stat-pill border-[#30363d] text-gray-500">
|
||||
${total} services
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Filter bar ─────────────────────────────────────────────────────────────
|
||||
|
||||
function renderFilterBar(data) {
|
||||
const bar = document.getElementById('filter-bar');
|
||||
bar.innerHTML = `
|
||||
<button class="filter-btn ${activeFilter === 'all' ? 'active' : ''}" onclick="setFilter('all')">Tout</button>
|
||||
`;
|
||||
|
||||
data.categories.forEach(cat => {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = `filter-btn ${activeFilter === cat.id ? 'active' : ''}`;
|
||||
btn.textContent = `${cat.icon} ${cat.name}`;
|
||||
btn.onclick = () => setFilter(cat.id);
|
||||
bar.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function setFilter(id) {
|
||||
activeFilter = id;
|
||||
if (lastData) {
|
||||
renderDashboard(lastData);
|
||||
renderFilterBar(lastData);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Countdown & refresh ────────────────────────────────────────────────────
|
||||
|
||||
function startCountdown() {
|
||||
refreshTimer = setInterval(() => {
|
||||
countdown--;
|
||||
const el = document.getElementById('refresh-timer');
|
||||
if (el) el.textContent = `↻ ${countdown}s`;
|
||||
|
||||
if (countdown <= 0) {
|
||||
loadAll();
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function resetCountdown() {
|
||||
countdown = REFRESH_INTERVAL;
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function openService(url, name) {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
function formatUrl(url) {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.host + (u.port ? '' : '') + (u.pathname !== '/' ? u.pathname : '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function showLoader() {
|
||||
document.getElementById('loader').classList.remove('hidden');
|
||||
document.getElementById('dashboard').classList.add('hidden');
|
||||
document.getElementById('error-panel').classList.add('hidden');
|
||||
}
|
||||
|
||||
function hideLoader() {
|
||||
document.getElementById('loader').classList.add('hidden');
|
||||
}
|
||||
|
||||
function showError(msg) {
|
||||
document.getElementById('loader').classList.add('hidden');
|
||||
document.getElementById('error-panel').classList.remove('hidden');
|
||||
document.getElementById('error-msg').textContent = msg;
|
||||
}
|
||||
|
||||
function closeModal(e) {
|
||||
if (e.target === document.getElementById('modal')) {
|
||||
document.getElementById('modal').classList.add('hidden');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/* PercoHub — Custom styles (complément Tailwind) */
|
||||
|
||||
/* ── Scrollbar ────────────────────────────────────────────────────────────── */
|
||||
::-webkit-scrollbar { width: 6px; height: 6px; }
|
||||
::-webkit-scrollbar-track { background: #0d1117; }
|
||||
::-webkit-scrollbar-thumb { background: #30363d; border-radius: 3px; }
|
||||
::-webkit-scrollbar-thumb:hover { background: #484f58; }
|
||||
|
||||
/* ── Service card ─────────────────────────────────────────────────────────── */
|
||||
.service-card {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
transition: border-color 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
}
|
||||
.service-card.has-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
.service-card.has-link:hover {
|
||||
border-color: #484f58;
|
||||
box-shadow: 0 0 0 1px #484f58, 0 4px 16px rgba(0,0,0,0.4);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Left accent bar */
|
||||
.service-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0; top: 0; bottom: 0;
|
||||
width: 3px;
|
||||
border-radius: 3px 0 0 3px;
|
||||
background: var(--accent-color, #30363d);
|
||||
}
|
||||
|
||||
/* ── Category accent colors ────────────────────────────────────────────────── */
|
||||
.cat-violet { --accent-color: #8b5cf6; }
|
||||
.cat-slate { --accent-color: #64748b; }
|
||||
.cat-amber { --accent-color: #f59e0b; }
|
||||
.cat-emerald { --accent-color: #10b981; }
|
||||
.cat-sky { --accent-color: #0ea5e9; }
|
||||
.cat-orange { --accent-color: #f97316; }
|
||||
.cat-rose { --accent-color: #f43f5e; }
|
||||
|
||||
/* ── Status dot ───────────────────────────────────────────────────────────── */
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.status-dot.online { background: #10b981; box-shadow: 0 0 6px #10b981; }
|
||||
.status-dot.offline { background: #ef4444; }
|
||||
.status-dot.warn { background: #f59e0b; box-shadow: 0 0 6px #f59e0b; }
|
||||
.status-dot.loading { background: #4b5563; animation: pulse-dot 1.2s infinite; }
|
||||
|
||||
@keyframes pulse-dot {
|
||||
0%, 100% { opacity: 0.3; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ── Status badge ─────────────────────────────────────────────────────────── */
|
||||
.badge {
|
||||
font-size: 0.65rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.badge-green { background: #10b98120; color: #34d399; border: 1px solid #10b98140; }
|
||||
.badge-red { background: #ef444420; color: #f87171; border: 1px solid #ef444440; }
|
||||
.badge-yellow { background: #f59e0b20; color: #fbbf24; border: 1px solid #f59e0b40; }
|
||||
.badge-gray { background: #8b949e20; color: #8b949e; border: 1px solid #8b949e40; }
|
||||
.badge-blue { background: #0ea5e920; color: #38bdf8; border: 1px solid #0ea5e940; }
|
||||
|
||||
/* ── Section header ──────────────────────────────────────────────────────── */
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid #30363d;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #e6edf3;
|
||||
}
|
||||
.section-count {
|
||||
font-size: 0.75rem;
|
||||
color: #8b949e;
|
||||
background: #21262d;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 9999px;
|
||||
padding: 0.1rem 0.55rem;
|
||||
}
|
||||
|
||||
/* ── Global stats pills ───────────────────────────────────────────────────── */
|
||||
.stat-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.65rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Filter button ────────────────────────────────────────────────────────── */
|
||||
.filter-btn {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid #30363d;
|
||||
background: transparent;
|
||||
color: #8b949e;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.filter-btn:hover,
|
||||
.filter-btn.active {
|
||||
border-color: #8b5cf6;
|
||||
color: #c4b5fd;
|
||||
background: #8b5cf620;
|
||||
}
|
||||
|
||||
/* ── Note badge ───────────────────────────────────────────────────────────── */
|
||||
.note-tag {
|
||||
font-size: 0.6rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
background: #21262d;
|
||||
color: #8b949e;
|
||||
border: 1px solid #30363d;
|
||||
max-width: 160px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Responsive grid ──────────────────────────────────────────────────────── */
|
||||
.services-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.services-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
Reference in New Issue
Block a user