feat: PercoHub v1 — dashboard centralisé homelab

This commit is contained in:
perco
2026-03-06 22:27:52 +01:00
commit b6d9f3a549
6 changed files with 1128 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
# PercoHub 🏠
Dashboard centralisé pour accéder à tous les services et projets du homelab de Perco.
## Stack
- **Backend** : PHP 8 (lecture `services.json`, check Docker + HTTP)
- **Frontend** : Tailwind CSS CDN + Vanilla JS
- **Données** : `services.json` — catalogue éditable manuellement
## Démarrage
```bash
nohup php -S 0.0.0.0:9300 -t /home/perco/projects/percohub/ > /tmp/percohub-php.log 2>&1 &
```
**URL locale** : http://192.168.1.29:9300
## Fonctionnalités
- ✅ Vue centralisée de tous les services (Docker + projets PHP)
- ✅ Status live (Docker inspect + HTTP ping)
- ✅ Catégories : Projets Luca / Infrastructure / Média / Domotique / Productivité / VPN Firefox
- ✅ Filtre par catégorie (navbar)
- ✅ Stats globales (online/offline)
- ✅ Auto-refresh toutes les 30s
- ✅ Clic sur une carte → ouvre l'URL dans un nouvel onglet
- ✅ Dark theme cohérent avec les autres projets
## Structure
```
percohub/
├── index.php # Page principale (shell HTML)
├── api.php # Backend : statuts Docker + HTTP
├── services.json # Catalogue des services (à éditer)
├── assets/
│ ├── style.css # Dark theme custom
│ └── app.js # Frontend : rendu, refresh, filtres
└── README.md
```
## Ajouter un service
Éditer `services.json` — ajouter un objet dans le tableau `services` de la bonne catégorie :
```json
{
"name": "Mon Service",
"desc": "Description courte",
"url": "http://192.168.1.29:PORT",
"docker": "nom-du-container",
"tags": ["tag1", "tag2"]
}
```
- `url` : null si pas d'interface web
- `docker` : null si ce n'est pas un container Docker (ex: serveur PHP)
- Si `docker` est renseigné → status via `docker inspect`
- Si seulement `url` → status via HTTP ping
## Ajouter une catégorie
Dans `services.json`, ajouter dans le tableau `categories` :
```json
{
"id": "ma-categorie",
"name": "Ma Catégorie",
"icon": "🔌",
"color": "violet",
"services": [...]
}
```
**Couleurs disponibles** : violet, slate, amber, emerald, sky, orange, rose
## API
| Endpoint | Description |
|----------|-------------|
| `/api.php?action=all` | Tous les services avec statuts |
| `/api.php?action=config` | Catalogue brut (sans checks) |
| `/api.php?action=docker` | Liste tous les containers Docker |
## Catégories actuelles
| Catégorie | Services |
|-----------|----------|
| 🛠️ Projets Luca | AgentStats, CronViz, MonStatut, AgentChat, HomeStatus |
| 🔧 Infrastructure | Portainer, Traefik, Grafana, InfluxDB, Dashdot, Watchtower |
| 🎬 Média | Plex, Jellyseerr, Sonarr, Radarr, Lidarr, Prowlarr, Jackett, qBittorrent, Pinchflat, Tautulli |
| 🏠 Domotique | Home Assistant, ESPhome, Syncthing, Grott, Bresser Live, Mosquitto |
| 💼 Productivité | Nextcloud, Gitea, VSCode, Planka, Actual Budget, CronMaster, Homarr |
| 🦊 VPN Firefox | retak, coucouze, roxxor, xouz, nale |
+184
View File
@@ -0,0 +1,184 @@
<?php
/**
* PercoHub API
* Retourne le catalogue des services avec leur statut en temps réel.
* - Docker containers : via `docker inspect`
* - Services HTTP (projets PHP) : via curl ping
*/
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
$action = $_GET['action'] ?? 'all';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function dockerStatus(string $name): array {
$raw = shell_exec("docker inspect --format '{{.State.Status}}|{{if .State.Health}}{{.State.Health.Status}}{{end}}' " . escapeshellarg($name) . " 2>/dev/null");
if (empty($raw)) return ['status' => 'absent', 'health' => null];
$parts = explode('|', trim($raw));
$status = $parts[0] ?? 'unknown';
$health = ($parts[1] ?? '') ?: null;
if ($health === '') $health = null;
return ['status' => $status, 'health' => $health];
}
function httpPing(string $url, int $timeout = 3): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_NOBODY => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_CONNECTTIMEOUT => $timeout,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_SSL_VERIFYHOST => false,
CURLOPT_USERAGENT => 'PercoHub/1.0',
]);
curl_exec($ch);
$code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$time = (int) (curl_getinfo($ch, CURLINFO_TOTAL_TIME) * 1000);
curl_close($ch);
$ok = $code >= 200 && $code < 400;
return ['http_code' => $code, 'ok' => $ok, 'ms' => $time];
}
function resolveServiceStatus(array $service): array {
$dockerName = $service['docker'] ?? null;
$url = $service['url'] ?? null;
// Self-check: ce service est le dashboard lui-même → toujours online
if (!empty($service['self'])) {
return ['online' => true, 'status_text' => 'online', 'docker' => null, 'http' => null];
}
$result = [
'online' => false,
'status_text' => 'inconnu',
'docker' => null,
'http' => null,
];
// 1. Docker check
if ($dockerName) {
$d = dockerStatus($dockerName);
$result['docker'] = $d;
if ($d['status'] === 'running') {
$result['online'] = true;
$result['status_text'] = $d['health'] === 'healthy' ? 'healthy' : 'running';
} elseif ($d['status'] === 'absent') {
$result['status_text'] = 'absent';
} else {
$result['status_text'] = $d['status']; // exited, paused, restarting...
}
}
// 2. HTTP check (for PHP projects without docker, or in addition)
if ($url && !$dockerName) {
$h = httpPing($url);
$result['http'] = $h;
$result['online'] = $h['ok'];
$result['status_text'] = $h['ok'] ? 'online' : ($h['http_code'] > 0 ? "http {$h['http_code']}" : 'offline');
}
return $result;
}
// ─── Routes ───────────────────────────────────────────────────────────────────
$configFile = __DIR__ . '/services.json';
if (!file_exists($configFile)) {
echo json_encode(['error' => 'services.json introuvable'], JSON_UNESCAPED_UNICODE);
exit;
}
$config = json_decode(file_get_contents($configFile), true);
if (!$config) {
echo json_encode(['error' => 'services.json invalide'], JSON_UNESCAPED_UNICODE);
exit;
}
if ($action === 'status') {
// Statut d'un seul service
$catId = $_GET['cat'] ?? null;
$svcIdx = (int)($_GET['idx'] ?? -1);
$found = null;
foreach ($config['categories'] as $cat) {
if ($cat['id'] === $catId && isset($cat['services'][$svcIdx])) {
$found = $cat['services'][$svcIdx];
break;
}
}
if (!$found) {
echo json_encode(['error' => 'service introuvable'], JSON_UNESCAPED_UNICODE);
exit;
}
echo json_encode(resolveServiceStatus($found), JSON_UNESCAPED_UNICODE);
exit;
}
if ($action === 'all') {
// Tous les services avec leur statut
$output = ['categories' => [], 'generated_at' => date('c'), 'total' => 0, 'online' => 0];
foreach ($config['categories'] as $cat) {
$catOut = [
'id' => $cat['id'],
'name' => $cat['name'],
'icon' => $cat['icon'],
'color' => $cat['color'],
'services' => [],
];
foreach ($cat['services'] as $svc) {
$statusInfo = resolveServiceStatus($svc);
$catOut['services'][] = array_merge($svc, [
'status' => $statusInfo,
]);
$output['total']++;
if ($statusInfo['online']) $output['online']++;
}
$output['categories'][] = $catOut;
}
$output['offline'] = $output['total'] - $output['online'];
echo json_encode($output, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
if ($action === 'config') {
// Catalogue brut sans status checks
echo json_encode($config, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
if ($action === 'docker') {
// Liste tous les containers Docker
$raw = shell_exec("docker ps -a --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}' 2>/dev/null");
$containers = [];
foreach (explode("\n", trim($raw)) as $line) {
if (empty($line)) continue;
$parts = explode('|', $line);
$containers[] = [
'name' => $parts[0] ?? '',
'image' => $parts[1] ?? '',
'status' => $parts[2] ?? '',
'ports' => $parts[3] ?? '',
];
}
echo json_encode(['containers' => $containers, 'count' => count($containers)], JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT);
exit;
}
echo json_encode(['error' => 'action invalide. Utilisez: all, status, config, docker'], JSON_UNESCAPED_UNICODE);
+258
View File
@@ -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');
}
}
+157
View File
@@ -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; }
}
+85
View File
@@ -0,0 +1,85 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PercoHub Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/assets/style.css" />
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
base: '#0d1117',
card: '#161b22',
border:'#30363d',
muted: '#8b949e',
}
}
}
}
</script>
</head>
<body class="dark bg-[#0d1117] text-gray-100 min-h-screen font-mono">
<!-- ── Navbar ─────────────────────────────────────────────────────────────── -->
<nav class="sticky top-0 z-50 bg-[#161b22] border-b border-[#30363d] px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-2xl">🏠</span>
<span class="text-lg font-bold text-white tracking-tight">PercoHub</span>
<span class="text-xs text-gray-500 ml-2 hidden sm:block">Dashboard centralisé</span>
</div>
<div class="flex items-center gap-4">
<!-- Filtres catégories -->
<div id="filter-bar" class="flex gap-2 flex-wrap justify-end hidden sm:flex"></div>
<!-- Stats globales -->
<div id="global-stats" class="flex items-center gap-3 text-sm">
<span class="text-gray-500"></span>
</div>
<!-- Refresh -->
<div class="flex items-center gap-2">
<span id="refresh-timer" class="text-xs text-gray-600"></span>
<button id="btn-refresh" onclick="loadAll(true)" title="Rafraîchir maintenant"
class="text-gray-400 hover:text-white transition text-sm border border-[#30363d] rounded px-2 py-1">
</button>
</div>
</div>
</nav>
<!-- ── Main ───────────────────────────────────────────────────────────────── -->
<main class="max-w-[1600px] mx-auto px-4 py-6">
<!-- Loader -->
<div id="loader" class="flex flex-col items-center justify-center py-24 gap-4">
<div class="w-8 h-8 border-4 border-violet-500 border-t-transparent rounded-full animate-spin"></div>
<p class="text-gray-500 text-sm">Chargement des services…</p>
</div>
<!-- Error -->
<div id="error-panel" class="hidden bg-red-900/30 border border-red-700 rounded-lg p-6 text-center">
<p class="text-red-400 text-lg">⚠️ Impossible de contacter l'API</p>
<p id="error-msg" class="text-gray-500 text-sm mt-2"></p>
</div>
<!-- Dashboard content -->
<div id="dashboard" class="hidden space-y-10"></div>
</main>
<!-- ── Modal Docker détail ─────────────────────────────────────────────────── -->
<div id="modal" class="hidden fixed inset-0 z-50 bg-black/60 flex items-center justify-center p-4" onclick="closeModal(event)">
<div class="bg-[#161b22] border border-[#30363d] rounded-xl max-w-lg w-full p-6 relative">
<button onclick="document.getElementById('modal').classList.add('hidden')"
class="absolute top-4 right-4 text-gray-500 hover:text-white text-xl">✕</button>
<div id="modal-content"></div>
</div>
</div>
<script src="/assets/app.js"></script>
</body>
</html>
+349
View File
@@ -0,0 +1,349 @@
{
"categories": [
{
"id": "luca",
"name": "Projets Luca",
"icon": "🛠️",
"color": "violet",
"services": [
{
"name": "PercoHub",
"desc": "Ce dashboard centralisé",
"url": "http://192.168.1.29:9300",
"docker": null,
"self": true,
"tags": ["dashboard"]
},
{
"name": "AgentStats",
"desc": "Stats & coûts des agents IA",
"url": "http://192.168.1.29:9000",
"docker": null,
"tags": ["ia", "monitoring"]
},
{
"name": "CronViz",
"desc": "Visualisation des cron jobs OpenClaw",
"url": "http://192.168.1.29:9100",
"docker": null,
"tags": ["cron", "monitoring"]
},
{
"name": "MonStatut",
"desc": "Monitoring uptime services réseau",
"url": "http://192.168.1.29:9200",
"docker": null,
"tags": ["monitoring"]
},
{
"name": "AgentChat",
"desc": "Interface chat avec les agents IA",
"url": null,
"docker": null,
"tags": ["ia", "chat"],
"note": "Non démarré — port à définir"
},
{
"name": "HomeStatus",
"desc": "Stats système temps réel (CPU, RAM, disques)",
"url": null,
"docker": null,
"tags": ["monitoring", "system"],
"note": "Non démarré — port à définir"
}
]
},
{
"id": "infra",
"name": "Infrastructure",
"icon": "🔧",
"color": "slate",
"services": [
{
"name": "Portainer",
"desc": "Gestion visuelle des conteneurs Docker",
"url": "https://portainer.nas.percolouco.com",
"docker": "portainer",
"tags": ["docker", "admin"]
},
{
"name": "Traefik",
"desc": "Reverse proxy — routing & certificats SSL",
"url": "http://192.168.1.29:8004",
"docker": "traefik",
"tags": ["proxy", "ssl"]
},
{
"name": "Grafana",
"desc": "Dashboards métriques & visualisation",
"url": "http://192.168.1.29:3010",
"docker": "grafana",
"tags": ["metrics", "monitoring"]
},
{
"name": "InfluxDB",
"desc": "Base de données time-series",
"url": "http://192.168.1.29:8086",
"docker": "influxdb",
"tags": ["database", "metrics"]
},
{
"name": "Dashdot",
"desc": "Dashboard système moderne (CPU, RAM, réseau)",
"url": "http://192.168.1.29:3001",
"docker": "dashboard-dash-1",
"tags": ["system", "monitoring"]
},
{
"name": "Watchtower",
"desc": "Mises à jour automatiques des conteneurs",
"url": null,
"docker": "watchtower",
"tags": ["docker", "updates"]
},
{
"name": "Gitea Runner",
"desc": "Runner CI/CD pour Gitea Actions",
"url": null,
"docker": "gitea-runner",
"tags": ["ci-cd"]
}
]
},
{
"id": "media",
"name": "Média",
"icon": "🎬",
"color": "amber",
"services": [
{
"name": "Plex",
"desc": "Serveur multimédia — films, séries, musique",
"url": "http://192.168.1.29:32400/web",
"docker": "plex",
"tags": ["streaming", "media"]
},
{
"name": "Jellyseerr",
"desc": "Demandes de contenu média",
"url": "http://192.168.1.29:5055",
"docker": "jellyseerr",
"tags": ["media", "requests"]
},
{
"name": "Sonarr",
"desc": "Gestion automatique des séries TV",
"url": "http://192.168.1.29:8989",
"docker": "sonarr",
"tags": ["tv", "automation"]
},
{
"name": "Radarr",
"desc": "Gestion automatique des films",
"url": "http://192.168.1.29:7878",
"docker": "radarr",
"tags": ["movies", "automation"]
},
{
"name": "Lidarr",
"desc": "Gestion automatique de la musique",
"url": "http://192.168.1.29:8686",
"docker": "lidarr",
"tags": ["music", "automation"]
},
{
"name": "Prowlarr",
"desc": "Gestionnaire d'indexeurs",
"url": "http://192.168.1.29:9696",
"docker": "prowlarr",
"tags": ["indexer"]
},
{
"name": "Jackett",
"desc": "Proxy indexeurs torrents",
"url": "http://192.168.1.29:9117",
"docker": "jackett",
"tags": ["indexer", "torrent"]
},
{
"name": "qBittorrent",
"desc": "Client torrent (via VPN Gluetun)",
"url": "http://192.168.1.29:1234",
"docker": "qbittorrent",
"tags": ["torrent", "vpn"]
},
{
"name": "Pinchflat",
"desc": "Téléchargement automatique YouTube",
"url": "http://192.168.1.29:8945",
"docker": "pinchflat-pinchflat-1",
"tags": ["youtube", "download"]
},
{
"name": "Tautulli",
"desc": "Statistiques et monitoring Plex",
"url": "http://192.168.1.29:8181",
"docker": "tautulli",
"tags": ["plex", "stats"]
},
{
"name": "FlareSolverr",
"desc": "Bypass Cloudflare pour indexeurs",
"url": "http://192.168.1.29:8191",
"docker": "flaresolverr",
"tags": ["proxy"]
}
]
},
{
"id": "domotique",
"name": "Domotique",
"icon": "🏠",
"color": "emerald",
"services": [
{
"name": "Home Assistant",
"desc": "Plateforme domotique centrale",
"url": "http://192.168.1.29:8123",
"docker": "homeassistant",
"tags": ["domotique", "automation"]
},
{
"name": "ESPhome",
"desc": "Firmware & gestion des devices ESP",
"url": null,
"docker": "esphome",
"tags": ["esp", "firmware"]
},
{
"name": "Syncthing",
"desc": "Synchronisation de fichiers P2P",
"url": "http://192.168.1.29:8384",
"docker": "syncthing",
"tags": ["sync", "files"]
},
{
"name": "Grott",
"desc": "Monitoring panneaux solaires Growatt",
"url": "http://192.168.1.29:5279",
"docker": "grott",
"tags": ["solar", "energy"]
},
{
"name": "Bresser Live",
"desc": "Station météo Bresser en temps réel",
"url": "http://192.168.1.29:8887",
"docker": "bresser-live",
"tags": ["weather", "sensors"]
},
{
"name": "Mosquitto",
"desc": "Broker MQTT",
"url": null,
"docker": "mosquitto",
"tags": ["mqtt", "iot"]
}
]
},
{
"id": "productivite",
"name": "Productivité",
"icon": "💼",
"color": "sky",
"services": [
{
"name": "Nextcloud",
"desc": "Cloud personnel — fichiers, calendrier, contacts",
"url": "http://192.168.1.29:8080",
"docker": "nextcloud",
"tags": ["cloud", "files"]
},
{
"name": "Gitea",
"desc": "Hébergement Git self-hosted",
"url": "http://192.168.1.29:3500",
"docker": "gitea",
"tags": ["git", "dev"]
},
{
"name": "VSCode",
"desc": "Éditeur de code dans le navigateur",
"url": "http://192.168.1.29:8888",
"docker": "vscode",
"tags": ["dev", "editor"]
},
{
"name": "Planka",
"desc": "Gestion de projets Kanban",
"url": "https://planka.nas.percolouco.com",
"docker": "planka",
"tags": ["kanban", "projects"]
},
{
"name": "Actual Budget",
"desc": "Gestion budget personnel",
"url": "http://192.168.1.29:5006",
"docker": "actual-server",
"tags": ["finance", "budget"]
},
{
"name": "CronMaster",
"desc": "Gestionnaire de tâches cron",
"url": "http://192.168.1.29:40123",
"docker": "cronmaster",
"tags": ["cron", "scheduler"]
},
{
"name": "Homarr",
"desc": "Dashboard de démarrage alternatif",
"url": "http://192.168.1.29:7575",
"docker": "homarr",
"tags": ["dashboard"]
}
]
},
{
"id": "vpn",
"name": "VPN Firefox",
"icon": "🦊",
"color": "orange",
"services": [
{
"name": "Firefox — retak",
"desc": "Instance Firefox via VPN Gluetun",
"url": "http://192.168.1.29:3000",
"docker": "vpn_mh_retak",
"tags": ["vpn", "browser"]
},
{
"name": "Firefox — coucouze",
"desc": "Instance Firefox via VPN Gluetun",
"url": "http://192.168.1.29:3100",
"docker": "vpn_mh_coucouze",
"tags": ["vpn", "browser"]
},
{
"name": "Firefox — roxxor",
"desc": "Instance Firefox via VPN Gluetun",
"url": "http://192.168.1.29:3200",
"docker": "vpn_mh_roxxor",
"tags": ["vpn", "browser"]
},
{
"name": "Firefox — xouz",
"desc": "Instance Firefox via VPN Gluetun",
"url": "http://192.168.1.29:3300",
"docker": "vpn_mh_xouz",
"tags": ["vpn", "browser"]
},
{
"name": "Firefox — nale",
"desc": "Instance Firefox via VPN Gluetun",
"url": "http://192.168.1.29:3400",
"docker": "vpn_mh_nale",
"tags": ["vpn", "browser"]
}
]
}
]
}