feat: PercoHub v1 — dashboard centralisé homelab
This commit is contained in:
@@ -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 |
|
||||||
@@ -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
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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
@@ -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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user