feat(status-monitor): v1.3.0 - design moderne, waiting, reverse proxy, /apps

This commit is contained in:
Windsurf
2026-07-20 21:05:22 +02:00
parent c63ff8a6a0
commit be3359e529
2 changed files with 701 additions and 440 deletions
+312 -306
View File
@@ -1,306 +1,312 @@
# Status Monitor - Homelab
**Application de monitoring des services Homelab**
**Version** : 1.2.0
**Auteur** : Windsurf
**Dernière mise à jour** : 20/07/2026
---
## 📋 Description
Application Python de monitoring des services Homelab. Elle vérifie régulièrement l'état (online/offline) de tous les services configurés et affiche un tableau de bord web en temps réel.
**Fonctionnalités** :
- Vérification SSH, HTTP, HTTPS
- Interface web responsive avec rafraîchissement automatique
- **Liens cliquables vers chaque service (IP:port ou URL publique HTTPS)**
- **Filtres par type de service (SSH, HTTP, HTTPS, Others)**
- URLs publiques configurables par service (`public_url`)
- Historique des changements de statut
- Support des certificats SSL auto-signés (mode insecure)
- Tableau de bord avec statistiques globales
---
## 🏗️ Architecture
```
Internet → Traefik (CT200) → status.peis.fr
CT201 (192.168.0.201:80)
Status Monitor (Python HTTP Server)
Vérifie tous les services configurés
```
---
## 🚀 Déploiement
### Pré-requis
- Python 3.x
- Accès réseau à tous les services à surveiller
- Port 80 disponible sur CT201
### Installation
```bash
# Sur CT201 (192.168.0.201)
apt update && apt install -y python3 python3-pip
# Créer le dossier
mkdir -p /opt/status-monitor
cd /opt/status-monitor
# Copier les fichiers depuis ce dépôt
# (voir section Workflow Git ci-dessous)
# Installer le service systemd
cp status-monitor.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable status-monitor
systemctl start status-monitor
# Vérifier
systemctl status status-monitor
curl http://localhost
```
---
## 🔄 Workflow Git (Processus Standard)
### Structure dans le dépôt
```
homelab-config/
└── status-monitor/
├── app.py # Application principale
├── deploy.sh # Script de déploiement
── status-monitor.service # Service systemd
├── VERSION # Version actuelle
── README.md # Ce fichier
```
### 1. Développer / Modifier le code
```bash
# Sur ta machine de développement (Windows)
# Modifier les fichiers dans : C:\Users\theo\.vibe\Proxmox\inventair\status-monitor\
# Exemple : Modifier app.py
nano C:\Users\theo\.vibe\Proxmox\inventair\status-monitor\app.py
```
### 2. Pousser vers Git depuis WSL
```bash
# Synchroniser IDE -> WSL si nécessaire
cp /mnt/c/Users/theo/homelab_ia/homelab-config/status-monitor/app.py ~/homelab-config/status-monitor/app.py
# Commiter et pousser avec l'utilisateur Windsurf
cd ~/homelab-config
git status
git diff status-monitor/
git add status-monitor/
git commit -m "Update Status Monitor - <description des changements>"
GIT_SSL_NO_VERIFY=true git push origin main
```
### 3. Déployer sur CT201
**Flux obligatoire : IDE (Windows) -> WSL -> CT200/CT100 (bastion 192.168.0.100) -> CT201 (192.168.0.201)**
```bash
# Méthode A : Exécuter le script de déploiement depuis WSL
wsl bash ~/homelab-config/status-monitor/deploy.sh
# Méthode B : Manuellement
# 1. IDE -> WSL
cp /mnt/c/Users/theo/homelab_ia/homelab-config/status-monitor/app.py ~/homelab-config/status-monitor/app.py
# 2. WSL -> CT201 (rebond via CT200/CT100)
scp -J root@192.168.0.100 -i ~/.ssh/id_ed25519 ~/homelab-config/status-monitor/app.py root@192.168.0.201:/tmp/app.py
# 3. Installation sur CT201
ssh -J root@192.168.0.100 -i ~/.ssh/id_ed25519 root@192.168.0.201 'mv /tmp/app.py /opt/status-monitor/app.py && systemctl restart status-monitor'
```
---
## 📦 Versioning
### Règles de versioning
- Format : `MAJOR.MINOR.PATCH` (ex: 1.1.0)
- **MAJOR** : Changements incompatibles (refonte complète)
- **MINOR** : Nouvelles fonctionnalités (compatibles)
- **PATCH** : Corrections de bugs
### Mettre à jour la version
```bash
# 1. Modifier le fichier VERSION
nano status-monitor/VERSION
# 2. Créer un tag Git (optionnel pour les releases)
git tag -a v1.1.0 -m "Release Status Monitor v1.1.0"
git push origin v1.1.0
# 3. Commit le changement de version
git add status-monitor/VERSION
git commit -m "Bump version to 1.1.0"
git push
```
### Historique des versions
| Version | Date | Changements |
|---------|------|-------------|
| 1.1.0 | 19/07/2026 | Version initiale dans Git |
| 1.0.0 | Avant | Version initiale (non versionnée) |
---
## 📊 Configuration des Services
Les services à surveiller sont définis dans le dictionnaire `SERVICES` dans `app.py`.
**Format** :
```python
SERVICES = {
"service_id": {
"name": "Nom affiché",
"type": "ssh" | "http" | "https",
"host": "IP ou hostname",
"port": port_number,
"url": "/chemin" (optionnel, pour http/https),
"public_url": "https://mon-service.peis.fr/" (optionnel, lien cliquable affiché),
"insecure": True | False (optionnel, pour https sans vérification SSL)
},
...
}
```
**Types disponibles** :
- `ssh` : Vérifie si le port SSH (22) est ouvert
- `http` : Vérifie la réponse HTTP sur un port (lien cliquable http://host:port/url)
- `https` : Vérifie la réponse HTTPS sur un port (lien cliquable https://host:port/url ou public_url)
---
## 🎨 API Endpoints
| Endpoint | Méthode | Description |
|----------|---------|-------------|
| `/` | GET | Tableau de bord HTML |
| `/api/status` | GET | JSON de tous les statuts |
| `/api/refresh` | GET | Rafraîchit tous les services |
| `/api/check/<service_id>` | GET | Vérifie un service spécifique |
---
## 🔧 Configuration Traefik
Le service est exposé via Traefik sur CT200 avec la configuration suivante dans `dynamic.yml` :
```yaml
http:
routers:
status-monitor:
rule: "Host(`status.peis.fr`)"
entryPoints:
- "websecure"
tls:
certResolver: "letsencrypt"
service: status-monitor
services:
status-monitor:
loadBalancer:
servers:
- url: "http://192.168.0.201:80"
```
**URL publique** : https://status.peis.fr
---
## ⚡ Commandes Utiles
### Sur CT201
```bash
# Voir les logs
journalctl -u status-monitor -f
# Redémarrer
systemctl restart status-monitor
# Tester localement
curl http://localhost
curl http://localhost/api/status | jq
```
### Depuis l'extérieur
```bash
# Vérifier l'accès
curl -vk https://status.peis.fr
curl -vk https://status.peis.fr/api/status
```
---
## 🛠️ Développement
### Ajouter un nouveau service
1. Éditer `app.py`
2. Ajouter une entrée dans `SERVICES`
3. Tester localement
4. Pousser vers Git
5. Déployer
### Tester localement
```bash
# Sur ta machine de dev
python3 app.py
# Accéder à http://localhost:80
```
---
## 📝 Credentials et Accès
| Élément | Valeur |
|---------|--------|
| **Dépôt Git** | git.peis.fr/VisualPCI/homelab-config |
| **Username Git** | Windsurf |
| **Token Git** | 3N14E7GWVSgi3K |
| **Auteur des commits** | Windsurf <windsurf@peis.fr> |
| **Serveur de déploiement** | CT201 (192.168.0.201) |
| **Bastion SSH** | CT200/CT100 (192.168.0.100) |
| **URL publique** | https://status.peis.fr |
| **Port** | 80 |
---
## 🎯 Prochaines Évolutions
- [ ] Ajouter l'authentification basique
- [ ] Ajouter des notifications (email, webhook Discord/Slack)
- [ ] Intégrer avec Grafana/Prometheus
- [ ] Historique persistant en base de données (SQLite/InfluxDB)
- [ ] Dashboard personnalisable (drag & drop des cartes)
- [ ] Temps de réponse / latence par service
- [ ] Catégories/groupes de services (Proxmox, Containers, VM, Externe)
- [ ] Mode sombre/clair
- [ ] Export CSV/PDF des statuts
- [ ] Page d'historique détaillée avec graphiques
- [ ] Gestion des certificats SSL (expiration, renouvellement)
- [ ] Support du ping ICMP en plus de TCP
---
*Documentation générée par Windsurf - 20/07/2026*
# Status Monitor - Homelab
**Application de monitoring des services Homelab**
**Version** : 1.3.0
**Auteur** : Windsurf
**Dernière mise à jour** : 20/07/2026
---
## 📋 Description
Application Python de monitoring des services Homelab. Elle vérifie régulièrement l'état (online/offline) de tous les services configurés et affiche un tableau de bord web en temps réel.
**Fonctionnalités** :
- Vérification SSH, HTTP, HTTPS
- Interface web responsive moderne (glassmorphism, gradient, rounded cards)
- **Liens cliquables vers chaque service (IP:port ou URL publique HTTPS)**
- **Filtres par type de service (SSH, HTTP, HTTPS, Reverse Proxy, Others)**
- **Affichage immédiat des tuiles au chargement (statut `waiting`)**
- **Label `reverse proxy` pour les services de routage Traefik**
- Page `/apps` listant les applications avec URL cliquables, ordre réordonnable et persistant (localStorage)
- URLs publiques configurables par service (`public_url`)
- Historique des changements de statut
- Support des certificats SSL auto-signés (mode insecure)
- Tableau de bord avec statistiques globales
---
## 🏗️ Architecture
```
Internet → Traefik (CT200) → status.peis.fr
CT201 (192.168.0.201:80)
Status Monitor (Python HTTP Server)
Vérifie tous les services configurés
```
---
## 🚀 Déploiement
### Pré-requis
- Python 3.x
- Accès réseau à tous les services à surveiller
- Port 80 disponible sur CT201
### Installation
```bash
# Sur CT201 (192.168.0.201)
apt update && apt install -y python3 python3-pip
# Créer le dossier
mkdir -p /opt/status-monitor
cd /opt/status-monitor
# Copier les fichiers depuis ce dépôt
# (voir section Workflow Git ci-dessous)
# Installer le service systemd
cp status-monitor.service /etc/systemd/system/
systemctl daemon-reload
systemctl enable status-monitor
systemctl start status-monitor
# Vérifier
systemctl status status-monitor
curl http://localhost
```
---
## 🔄 Workflow Git (Processus Standard)
### Structure dans le dépôt
```
homelab-config/
── status-monitor/
├── app.py # Application principale
── deploy.sh # Script de déploiement
├── status-monitor.service # Service systemd
├── VERSION # Version actuelle
└── README.md # Ce fichier
```
### 1. Développer / Modifier le code
```bash
# Sur ta machine de développement (Windows)
# Modifier les fichiers dans : C:\Users\theo\.vibe\Proxmox\inventair\status-monitor\
# Exemple : Modifier app.py
nano C:\Users\theo\.vibe\Proxmox\inventair\status-monitor\app.py
```
### 2. Pousser vers Git depuis WSL
```bash
# Synchroniser IDE -> WSL si nécessaire
cp /mnt/c/Users/theo/homelab_ia/homelab-config/status-monitor/app.py ~/homelab-config/status-monitor/app.py
# Commiter et pousser avec l'utilisateur Windsurf
cd ~/homelab-config
git status
git diff status-monitor/
git add status-monitor/
git commit -m "Update Status Monitor - <description des changements>"
GIT_SSL_NO_VERIFY=true git push origin main
```
### 3. Déployer sur CT201
**Flux obligatoire : IDE (Windows) -> WSL -> CT200/CT100 (bastion 192.168.0.100) -> CT201 (192.168.0.201)**
```bash
# Méthode A : Exécuter le script de déploiement depuis WSL
wsl bash ~/homelab-config/status-monitor/deploy.sh
# Méthode B : Manuellement
# 1. IDE -> WSL
cp /mnt/c/Users/theo/homelab_ia/homelab-config/status-monitor/app.py ~/homelab-config/status-monitor/app.py
# 2. WSL -> CT201 (rebond via CT200/CT100)
scp -J root@192.168.0.100 -i ~/.ssh/id_ed25519 ~/homelab-config/status-monitor/app.py root@192.168.0.201:/tmp/app.py
# 3. Installation sur CT201
ssh -J root@192.168.0.100 -i ~/.ssh/id_ed25519 root@192.168.0.201 'mv /tmp/app.py /opt/status-monitor/app.py && systemctl restart status-monitor'
```
---
## 📦 Versioning
### Règles de versioning
- Format : `MAJOR.MINOR.PATCH` (ex: 1.1.0)
- **MAJOR** : Changements incompatibles (refonte complète)
- **MINOR** : Nouvelles fonctionnalités (compatibles)
- **PATCH** : Corrections de bugs
### Mettre à jour la version
```bash
# 1. Modifier le fichier VERSION
nano status-monitor/VERSION
# 2. Créer un tag Git (optionnel pour les releases)
git tag -a v1.1.0 -m "Release Status Monitor v1.1.0"
git push origin v1.1.0
# 3. Commit le changement de version
git add status-monitor/VERSION
git commit -m "Bump version to 1.1.0"
git push
```
### Historique des versions
| Version | Date | Changements |
|---------|------|-------------|
| 1.3.0 | 20/07/2026 | Design modernisé, tuiles waiting, filtre Reverse Proxy, label reverse proxy, page `/apps` ordonnable |
| 1.2.0 | 20/07/2026 | Liens cliquables, `public_url`, filtres par type, Gitea séparé |
| 1.1.0 | 19/07/2026 | Version initiale dans Git |
| 1.0.0 | Avant | Version initiale (non versionnée) |
---
## 📊 Configuration des Services
Les services à surveiller sont définis dans le dictionnaire `SERVICES` dans `app.py`.
**Format** :
```python
SERVICES = {
"service_id": {
"name": "Nom affiché",
"type": "ssh" | "http" | "https",
"host": "IP ou hostname",
"port": port_number,
"url": "/chemin" (optionnel, pour http/https),
"public_url": "https://mon-service.peis.fr/" (optionnel, lien cliquable affiché),
"insecure": True | False (optionnel, pour https sans vérification SSL)
},
...
}
```
**Types disponibles** :
- `ssh` : Vérifie si le port SSH (22) est ouvert
- `http` : Vérifie la réponse HTTP sur un port (lien cliquable http://host:port/url)
- `https` : Vérifie la réponse HTTPS sur un port (lien cliquable https://host:port/url ou public_url)
---
## 🎨 API Endpoints
| Endpoint | Méthode | Description |
|----------|---------|-------------|
| `/` | GET | Tableau de bord HTML |
| `/apps` | GET | Liste des applications (URL cliquables, ordre persistant côté client) |
| `/api/status` | GET | JSON de tous les statuts |
| `/api/refresh` | GET | Rafraîchit tous les services |
| `/api/check/<service_id>` | GET | Vérifie un service spécifique |
---
## 🔧 Configuration Traefik
Le service est exposé via Traefik sur CT200 avec la configuration suivante dans `dynamic.yml` :
```yaml
http:
routers:
status-monitor:
rule: "Host(`status.peis.fr`)"
entryPoints:
- "websecure"
tls:
certResolver: "letsencrypt"
service: status-monitor
services:
status-monitor:
loadBalancer:
servers:
- url: "http://192.168.0.201:80"
```
**URL publique** : https://status.peis.fr
---
## ⚡ Commandes Utiles
### Sur CT201
```bash
# Voir les logs
journalctl -u status-monitor -f
# Redémarrer
systemctl restart status-monitor
# Tester localement
curl http://localhost
curl http://localhost/api/status | jq
```
### Depuis l'extérieur
```bash
# Vérifier l'accès
curl -vk https://status.peis.fr
curl -vk https://status.peis.fr/api/status
```
---
## 🛠️ Développement
### Ajouter un nouveau service
1. Éditer `app.py`
2. Ajouter une entrée dans `SERVICES`
3. Tester localement
4. Pousser vers Git
5. Déployer
### Tester localement
```bash
# Sur ta machine de dev
python3 app.py
# Accéder à http://localhost:80
```
---
## 📝 Credentials et Accès
| Élément | Valeur |
|---------|--------|
| **Dépôt Git** | git.peis.fr/VisualPCI/homelab-config |
| **Username Git** | Windsurf |
| **Token Git** | 3N14E7GWVSgi3K |
| **Auteur des commits** | Windsurf <windsurf@peis.fr> |
| **Serveur de déploiement** | CT201 (192.168.0.201) |
| **Bastion SSH** | CT200/CT100 (192.168.0.100) |
| **URL publique** | https://status.peis.fr |
| **Port** | 80 |
---
## 🎯 Prochaines Évolutions
- [ ] Ajouter l'authentification basique
- [ ] Ajouter des notifications (email, webhook Discord/Slack)
- [ ] Intégrer avec Grafana/Prometheus
- [ ] Historique persistant en base de données (SQLite/InfluxDB)
- [ ] Dashboard personnalisable (drag & drop des cartes)
- [ ] Temps de réponse / latence par service
- [ ] Catégories/groupes de services (Proxmox, Containers, VM, Externe)
- [ ] Mode sombre/clair
- [ ] Export CSV/PDF des statuts
- [ ] Page d'historique détaillée avec graphiques
- [ ] Gestion des certificats SSL (expiration, renouvellement)
- [ ] Support du ping ICMP en plus de TCP
---
*Documentation générée par Windsurf - 20/07/2026*
+389 -134
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Homelab Status Monitor v1.2.0"""
"""Homelab Status Monitor v1.3.0"""
import socket
import ssl
@@ -9,58 +9,59 @@ import threading
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
VERSION = "1.2.0"
VERSION = "1.3.0"
# Service configuration: name, type (ssh/http/https), host, port, url (for http/https), insecure (for https)
# public_url: external HTTPS URL shown as clickable link (optional)
# label: extra tag shown as badge on card, e.g. "reverse proxy" (optional)
SERVICES = {
# Containers LXC
"ct101-mqtt": {"name": "MQTT", "type": "ssh", "host": "192.168.0.5", "port": 22},
"ct101-mqtt-http": {"name": "MQTT HTTP", "type": "http", "host": "192.168.0.5", "port": 1883, "url": "/"},
"ct102-frigate": {"name": "Frigate", "type": "ssh", "host": "192.168.0.91", "port": 22},
"ct102-frigate-http": {"name": "Frigate Web", "type": "http", "host": "192.168.0.91", "port": 5000, "url": "/"},
"ct103-adguard": {"name": "AdGuard", "type": "ssh", "host": "192.168.0.92", "port": 22},
"ct103-adguard-http": {"name": "AdGuard Web", "type": "http", "host": "192.168.0.92", "port": 80, "url": "/"},
"ct103-adguard-https": {"name": "AdGuard HTTPS", "type": "https", "host": "192.168.0.92", "port": 443, "url": "/", "insecure": True},
"ct105-alpine": {"name": "Alpine-Docker", "type": "ssh", "host": "192.168.0.155", "port": 22},
"ct105-alpine-docker": {"name": "Alpine Docker API", "type": "http", "host": "192.168.0.155", "port": 2375, "url": "/"},
"ct106-zigbee": {"name": "Zigbee2MQTT", "type": "ssh", "host": "192.168.0.177", "port": 22},
"ct106-zigbee-http": {"name": "Zigbee2MQTT Web", "type": "http", "host": "192.168.0.177", "port": 8081, "url": "/"},
# Traefik (CT200/CT100) - reverse proxy, no other service mixed here
"ct200-traefik": {"name": "Traefik Proxy", "type": "ssh", "host": "192.168.0.100", "port": 22},
"ct200-traefik-http": {"name": "Traefik HTTP", "type": "http", "host": "192.168.0.100", "port": 80, "url": "/"},
"ct200-traefik-https": {"name": "Traefik HTTPS", "type": "https", "host": "192.168.0.100", "port": 443, "url": "/", "public_url": "https://traefik.peis.fr/dashboard/", "insecure": True},
"ct200-traefik-https": {"name": "Traefik Dashboard", "type": "https", "host": "192.168.0.100", "port": 443, "url": "/", "public_url": "https://traefik.peis.fr/dashboard/", "insecure": True},
# Status Monitor (CT201)
"ct201-status": {"name": "Status Monitor", "type": "ssh", "host": "192.168.0.201", "port": 22},
"ct201-status-http": {"name": "Status Monitor Web", "type": "http", "host": "192.168.0.201", "port": 80, "url": "/"},
"ct201-status-https": {"name": "status.peis.fr", "type": "https", "host": "status.peis.fr", "port": 443, "url": "/", "public_url": "https://status.peis.fr/", "insecure": True},
"ct201-status-https": {"name": "status.peis.fr", "type": "https", "host": "status.peis.fr", "port": 443, "url": "/", "public_url": "https://status.peis.fr/", "label": "reverse proxy", "insecure": True},
# Gitea (CT202) - own dedicated service, not mixed with Traefik
"ct202-gitea": {"name": "Gitea", "type": "ssh", "host": "192.168.0.202", "port": 22},
"ct202-gitea-http": {"name": "Gitea Web", "type": "http", "host": "192.168.0.202", "port": 3000, "url": "/", "public_url": "https://git.peis.fr/"},
"ct202-gitea-https": {"name": "git.peis.fr", "type": "https", "host": "git.peis.fr", "port": 443, "url": "/", "public_url": "https://git.peis.fr/", "insecure": True},
"ct202-gitea-https": {"name": "git.peis.fr", "type": "https", "host": "git.peis.fr", "port": 443, "url": "/", "public_url": "https://git.peis.fr/", "label": "reverse proxy", "insecure": True},
# VM
"vm100-ha": {"name": "Home Assistant", "type": "ssh", "host": "192.168.0.43", "port": 22},
"vm100-ha-http": {"name": "Home Assistant Web", "type": "http", "host": "192.168.0.43", "port": 8123, "url": "/", "public_url": "https://ha.peis.fr/"},
# Proxmox hosts
"prox": {"name": "Proxmox (prox)", "type": "ssh", "host": "192.168.0.94", "port": 22},
"prox2": {"name": "Proxmox (prox2)", "type": "ssh", "host": "192.168.0.95", "port": 22},
# Raspberry Pi
"pi3": {"name": "Raspberry Pi 3", "type": "ssh", "host": "192.168.0.156", "port": 22},
# External HTTPS endpoints
"ha-peis-fr": {"name": "ha.peis.fr", "type": "https", "host": "ha.peis.fr", "port": 443, "url": "/", "public_url": "https://ha.peis.fr/", "insecure": True},
"traefik-dash": {"name": "traefik.peis.fr", "type": "https", "host": "traefik.peis.fr", "port": 443, "url": "/dashboard/", "public_url": "https://traefik.peis.fr/dashboard/", "insecure": True},
"test-peis-fr": {"name": "test.peis.fr", "type": "https", "host": "test.peis.fr", "port": 443, "url": "/", "public_url": "https://test.peis.fr/", "insecure": True},
# External HTTPS endpoints (Traefik reverse proxy)
"ha-peis-fr": {"name": "ha.peis.fr", "type": "https", "host": "ha.peis.fr", "port": 443, "url": "/", "public_url": "https://ha.peis.fr/", "label": "reverse proxy", "insecure": True},
"traefik-dash": {"name": "traefik.peis.fr", "type": "https", "host": "traefik.peis.fr", "port": 443, "url": "/dashboard/", "public_url": "https://traefik.peis.fr/dashboard/", "label": "reverse proxy", "insecure": True},
"test-peis-fr": {"name": "test.peis.fr", "type": "https", "host": "test.peis.fr", "port": 443, "url": "/", "public_url": "https://test.peis.fr/", "label": "reverse proxy", "insecure": True},
}
status_history = {}
@@ -166,67 +167,192 @@ def check_all():
return new_cache
# HTML Template (using a function to avoid f-string escaping issues)
# HTML Templates
def generate_html():
"""Generate the HTML page."""
"""Generate the main dashboard HTML page."""
services_json = json.dumps(SERVICES)
return '''<!DOCTYPE html>
<html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Homelab Status Monitor v''' + VERSION + '''</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; background: #0f172a; color: #e2e8f0; margin: 0; padding: 20px; }
:root {
--bg: #0b0f19;
--surface: rgba(30, 41, 59, 0.65);
--surface-solid: #151d2a;
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #60a5fa;
--accent-2: #818cf8;
--online: #22c55e;
--offline: #ef4444;
--warning: #f59e0b;
--waiting: #64748b;
}
* { box-sizing: border-box; }
body {
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
background: radial-gradient(circle at top left, #172033 0%, var(--bg) 40%), var(--bg);
color: var(--text);
margin: 0;
padding: 24px;
min-height: 100vh;
}
.container { max-width: 1400px; margin: 0 auto; }
h1 { color: #60a5fa; text-align: center; margin-bottom: 5px; }
.version { text-align: center; color: #64748b; font-size: 0.85em; margin-bottom: 15px; }
.controls { text-align: center; margin: 20px 0; position: relative; }
button { padding: 8px 16px; background: #3b82f6; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9em; }
button:hover { background: #2563eb; }
button:disabled { opacity: 0.7; cursor: not-allowed; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 15px; margin-top: 20px; }
.card { background: #1e293b; padding: 15px; border-radius: 8px; border-left: 4px solid; position: relative; }
.card.online { border-left-color: #22c55e; }
.card.offline { border-left-color: #ef4444; }
.card.timeout { border-left-color: #f97316; }
.card.ssl_error { border-left-color: #f97316; }
.card.error { border-left-color: #ef4444; }
.header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 10px; }
.name { font-weight: bold; font-size: 1.1em; flex: 1; }
.type { font-size: 0.8em; background: #334155; padding: 2px 8px; border-radius: 12px; color: #94a3b8; }
.badge { padding: 4px 12px; border-radius: 12px; font-size: 0.85em; font-weight: bold; }
.badge.online { background: #166534; color: #dcfce7; }
.badge.offline { background: #991b1b; color: #fca5a5; }
.badge.timeout { background: #92400e; color: #fde68a; }
.badge.ssl_error { background: #92400e; color: #fde68a; }
.badge.error { background: #991b1b; color: #fca5a5; }
.details { margin-top: 10px; font-size: 0.85em; color: #94a3b8; }
.error { color: #fca5a5; margin-top: 5px; font-size: 0.85em; word-break: break-word; }
.summary { display: grid; grid-template-columns: repeat(4, 1fr); gap: 15px; margin: 20px 0; }
.card-sum { background: #1e293b; padding: 15px; border-radius: 8px; text-align: center; }
.card-sum .count { font-size: 2em; font-weight: bold; color: #60a5fa; }
.card-sum .label { color: #94a3b8; }
.history-note { font-size: 0.75em; color: #64748b; margin-top: 5px; }
.refresh-btn { position: absolute; top: 10px; right: 10px; background: transparent; border: none; cursor: pointer; font-size: 1.2em; padding: 0; line-height: 1; }
.refresh-btn:hover { opacity: 0.8; }
.refresh-btn:disabled { cursor: not-allowed; opacity: 0.5; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.loading-spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid #334155; border-top-color: #60a5fa; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 8px; }
.filters { text-align: center; margin: 15px 0; }
.filter-btn { background: #334155; color: #94a3b8; margin: 0 5px 5px 0; }
.filter-btn:hover { background: #475569; }
.filter-btn.active { background: #3b82f6; color: white; }
.details a { color: #60a5fa; text-decoration: none; }
header { text-align: center; margin-bottom: 24px; position: relative; }
h1 {
font-size: 2.4em;
font-weight: 800;
margin: 0;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.02em;
}
.version { color: var(--muted); font-size: 0.9em; margin-top: 6px; }
.topbar {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin: 20px 0;
}
.topbar a, .topbar button {
padding: 10px 18px;
border-radius: 999px;
border: 1px solid rgba(148, 163, 184, 0.25);
background: var(--surface);
color: var(--text);
font-size: 0.9em;
cursor: pointer;
text-decoration: none;
backdrop-filter: blur(12px);
transition: transform 0.15s, background 0.2s, box-shadow 0.2s;
}
.topbar a:hover, .topbar button:hover:not(:disabled) {
background: rgba(96, 165, 250, 0.18);
transform: translateY(-1px);
box-shadow: 0 4px 20px rgba(96, 165, 250, 0.12);
}
.topbar button:disabled { opacity: 0.6; cursor: not-allowed; }
.filters { text-align: center; margin: 18px 0; }
.filter-btn {
background: transparent;
border: 1px solid rgba(148, 163, 184, 0.25);
color: var(--muted);
margin: 0 5px 8px 0;
border-radius: 999px;
padding: 8px 18px;
backdrop-filter: blur(10px);
}
.filter-btn.active { background: linear-gradient(90deg, var(--accent), var(--accent-2)); color: #fff; border-color: transparent; font-weight: 600; }
.summary { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; margin: 22px 0; }
.card-sum {
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.15);
border-radius: 16px;
padding: 18px;
text-align: center;
backdrop-filter: blur(14px);
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
}
.card-sum .count { font-size: 2.2em; font-weight: 800; color: var(--accent); }
.card-sum .label { color: var(--muted); font-size: 0.9em; margin-top: 4px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 18px; margin-top: 24px; }
.card {
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 18px;
padding: 18px;
position: relative;
backdrop-filter: blur(16px);
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
transition: transform 0.2s, box-shadow 0.2s;
overflow: hidden;
}
.card::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; height: 4px;
background: var(--waiting);
}
.card.online::before { background: var(--online); box-shadow: 0 0 14px var(--online); }
.card.offline::before { background: var(--offline); box-shadow: 0 0 14px var(--offline); }
.card.timeout::before, .card.ssl_error::before { background: var(--warning); box-shadow: 0 0 14px var(--warning); }
.card.error::before { background: var(--offline); box-shadow: 0 0 14px var(--offline); }
.card.waiting::before { background: var(--waiting); }
.card:hover { transform: translateY(-3px); box-shadow: 0 16px 48px rgba(0,0,0,0.28); }
.card-header { display: flex; justify-content: space-between; align-items: center; gap: 10px; margin-bottom: 12px; }
.name { font-weight: 700; font-size: 1.15em; flex: 1; }
.badges-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.tag {
font-size: 0.72em;
padding: 4px 10px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.03em;
font-weight: 700;
}
.tag.type { background: rgba(148, 163, 184, 0.18); color: var(--muted); }
.tag.label { background: linear-gradient(90deg, rgba(96,165,250,0.2), rgba(129,140,248,0.2)); color: var(--accent); border: 1px solid rgba(96,165,250,0.25); }
.badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 999px;
font-size: 0.85em;
font-weight: 700;
margin-top: 6px;
}
.badge::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.badge.online { background: rgba(34, 197, 94, 0.12); color: var(--online); }
.badge.offline { background: rgba(239, 68, 68, 0.12); color: var(--offline); }
.badge.timeout, .badge.ssl_error { background: rgba(245, 158, 11, 0.12); color: var(--warning); }
.badge.error { background: rgba(239, 68, 68, 0.12); color: var(--offline); }
.badge.waiting { background: rgba(100, 116, 139, 0.12); color: var(--waiting); }
.details { margin-top: 14px; font-size: 0.88em; color: var(--muted); }
.details a { color: var(--accent); text-decoration: none; }
.details a:hover { text-decoration: underline; }
.error { color: #fca5a5; margin-top: 8px; font-size: 0.85em; word-break: break-word; }
.history-note { font-size: 0.78em; color: var(--muted); margin-top: 10px; }
.refresh-btn {
width: 32px; height: 32px;
border-radius: 50%;
border: 1px solid rgba(148, 163, 184, 0.25);
background: var(--surface-solid);
color: var(--text);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 0.95em;
transition: background 0.2s, transform 0.15s;
padding: 0;
flex-shrink: 0;
}
.refresh-btn:hover:not(:disabled) { background: rgba(96, 165, 250, 0.22); transform: rotate(90deg); }
.refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.loading-spinner { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(148,163,184,0.25); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
.loading { width: 16px; height: 16px; border-radius: 50%; border: 2px solid rgba(148,163,184,0.25); border-top-color: var(--accent); animation: spin 0.9s linear infinite; }
#ts { color: var(--muted); font-size: 0.85em; margin-left: 10px; }
#global-loading { margin-left: 8px; vertical-align: middle; }
</style>
</head>
<body>
<div class="container">
<h1>Homelab Status Monitor</h1>
<div class="version">v''' + VERSION + '''</div>
<div class="controls">
<header>
<h1>Homelab Status Monitor</h1>
<div class="version">v''' + VERSION + '''</div>
</header>
<div class="topbar">
<button class="refresh-all" onclick="refreshAll()">Refresh All</button>
<button onclick="location.reload()">Reload Page</button>
<a href="/apps">Applications List</a>
<span id="ts"></span>
<span id="global-loading"></span>
</div>
@@ -235,6 +361,7 @@ def generate_html():
<button class="filter-btn" data-filter="ssh" onclick="setFilter('ssh')">SSH</button>
<button class="filter-btn" data-filter="http" onclick="setFilter('http')">HTTP</button>
<button class="filter-btn" data-filter="https" onclick="setFilter('https')">HTTPS</button>
<button class="filter-btn" data-filter="reverse" onclick="setFilter('reverse')">Reverse Proxy</button>
<button class="filter-btn" data-filter="others" onclick="setFilter('others')">Others</button>
</div>
<div class="summary">
@@ -246,19 +373,27 @@ def generate_html():
<div class="grid" id="grid"></div>
</div>
<script>
const SERVICE_DEFS = ''' + services_json + ''';
let data = {};
let history = {};
let refreshInProgress = false;
let activeFilter = 'all';
function initWaiting() {
const now = new Date().toISOString();
for (const [id, cfg] of Object.entries(SERVICE_DEFS)) {
data[id] = {config: cfg, result: {status: "waiting"}, time: now};
}
render();
}
function getClass(s) {
var classes = {"online": "online", "offline": "offline", "timeout": "timeout", "error": "error", "ssl_error": "ssl_error"};
return classes[s] || "offline";
return {"online": "online", "offline": "offline", "timeout": "timeout", "error": "error", "ssl_error": "ssl_error", "waiting": "waiting"}[s] || "offline";
}
function fmtHist(sid) {
if (history[sid]) {
var h = history[sid];
const h = history[sid];
return "Changed: " + h.old_status + " -> " + h.new_status + " at " + new Date(h.changed_at).toLocaleTimeString();
}
return "";
@@ -266,34 +401,26 @@ def generate_html():
function setGlobalLoad(b) {
refreshInProgress = b;
var elem = document.getElementById("global-loading");
elem.innerHTML = b ? '<span class="loading-spinner"></span>' : '';
document.getElementById("global-loading").innerHTML = b ? '<span class="loading-spinner"></span>' : '';
}
function setFilter(type) {
activeFilter = type;
document.querySelectorAll('.filter-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.getAttribute('data-filter') === type);
});
document.querySelectorAll('.filter-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.filter === type));
render();
}
function buildLink(c) {
if (c.public_url) {
return "<a href='" + c.public_url + "' target='_blank' rel='noopener noreferrer'>" + c.public_url + "</a>";
}
if (c.type === 'http') {
return "<a href='http://" + c.host + ":" + c.port + c.url + "' target='_blank' rel='noopener noreferrer'>" + c.host + ":" + c.port + c.url + "</a>";
}
if (c.type === 'https') {
return "<a href='https://" + c.host + ":" + c.port + c.url + "' target='_blank' rel='noopener noreferrer'>" + c.host + ":" + c.port + c.url + "</a>";
}
if (c.public_url) return "<a href='" + c.public_url + "' target='_blank' rel='noopener noreferrer'>" + c.public_url + "</a>";
if (c.type === 'http') return "<a href='http://" + c.host + ":" + c.port + c.url + "' target='_blank' rel='noopener noreferrer'>" + c.host + ":" + c.port + c.url + "</a>";
if (c.type === 'https') return "<a href='https://" + c.host + ":" + c.port + c.url + "' target='_blank' rel='noopener noreferrer'>" + c.host + ":" + c.port + c.url + "</a>";
return c.host + ":" + c.port;
}
function matchesFilter(c) {
if (activeFilter === 'all') return true;
if (activeFilter === 'others') return c.type !== 'ssh' && c.type !== 'http' && c.type !== 'https';
if (activeFilter === 'reverse') return c.label === 'reverse proxy';
return c.type === activeFilter;
}
@@ -306,19 +433,22 @@ def generate_html():
t++;
if (s === "online") ok++;
else if (s === "offline" || s === "error" || s === "timeout" || s === "ssl_error") ko++;
else err++;
else if (s !== "waiting") err++;
const bc = getClass(s);
const link = buildLink(c);
const label = c.label ? "<span class='tag label'>" + c.label + "</span>" : "";
const link = s === 'waiting' ? "<span class='loading'></span> Checking..." : buildLink(c);
h += "<div class='card ' + bc + ' data-type='" + c.type + "'>";
h += "<button class='refresh-btn' onclick='refreshService(\'" + id + "\')' title='Refresh'>&#x1F504;</button>";
h += "<div class='header'><div class='name'>" + c.name + "</div><span class='type'>" + c.type + "</span></div>";
h += "<div class='card " + bc + "'>";
h += "<div class='card-header'>";
h += "<div class='name'>" + c.name + "</div>";
h += "<div class='badges-row'>" + label + "<span class='tag type'>" + c.type + "</span><button class='refresh-btn' onclick='refreshService(\"" + id + "\")' title='Refresh'>&#x21bb;</button></div>";
h += "</div>";
h += "<span class='badge " + bc + "'>" + s.replace(/_/g, " ") + "</span>";
h += "<div class='details'>" + link + "</div>";
if (r.error) h += "<div class='error'>" + r.error + "</div>";
if (r.error && s !== 'waiting') h += "<div class='error'>" + r.error + "</div>";
if (r.status_code) h += "<div class='details'>Status: " + r.status_code + "</div>";
h += "<div class='details'>Checked: " + new Date(v.time).toLocaleTimeString() + "</div>";
if (s !== 'waiting') h += "<div class='details'>Checked: " + new Date(v.time).toLocaleTimeString() + "</div>";
const hist = fmtHist(id);
if (hist) h += "<div class='history-note'>" + hist + "</div>";
h += "</div>";
@@ -333,21 +463,15 @@ def generate_html():
}
function refreshService(sid) {
const btn = event.target;
const btn = event.currentTarget;
const original = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="loading-spinner"></span>';
fetch("/api/check/" + encodeURIComponent(sid))
.then(r => r.json())
.then(d => {
data[sid] = d;
if (d.history) history[sid] = d.history;
render();
})
.then(d => { data[sid] = d; if (d.history) history[sid] = d.history; render(); })
.catch(err => console.error("Error:", err))
.finally(() => {
btn.disabled = false;
btn.innerHTML = "🔄";
});
.finally(() => { btn.disabled = false; btn.innerHTML = original; });
}
function refreshAll() {
@@ -357,46 +481,171 @@ def generate_html():
btn.innerHTML = '<span class="loading-spinner"></span> Refreshing...';
fetch("/api/refresh")
.then(r => r.json())
.then(d => {
data = d.data;
history = d.history || {};
render();
})
.then(d => { data = d.data; history = d.history || {}; render(); })
.catch(err => console.error("Error:", err))
.finally(() => {
setGlobalLoad(false);
btn.disabled = false;
btn.innerHTML = "Refresh All";
});
.finally(() => { setGlobalLoad(false); btn.disabled = false; btn.innerHTML = "Refresh All"; });
}
// Initial load
fetch("/api/status")
.then(r => r.json())
.then(d => {
data = d.data;
history = d.history || {};
render();
})
.catch(err => console.error("Error:", err));
// Auto-refresh every 30 seconds
setInterval(function() {
function fetchStatus() {
setGlobalLoad(true);
fetch("/api/status")
.then(r => r.json())
.then(function(d) {
data = d.data;
history = d.history || {};
render();
})
.catch(function(err) {
console.error("Error:", err);
})
.finally(function() {
setGlobalLoad(false);
.then(d => { data = d.data; history = d.history || {}; render(); })
.catch(err => console.error("Error:", err))
.finally(() => setGlobalLoad(false));
}
initWaiting();
fetchStatus();
setInterval(fetchStatus, 30000);
</script>
</body>
</html>'''
def generate_apps_html():
"""Generate the applications list page with sortable and persistent order."""
apps = []
for sid, cfg in SERVICES.items():
url = None
if cfg.get("public_url"):
url = cfg["public_url"]
elif cfg.get("type") in ("http", "https"):
proto = cfg["type"]
url = f"{proto}://{cfg['host']}:{cfg['port']}{cfg.get('url', '/')}"
if url:
apps.append({"id": sid, "name": cfg["name"], "url": url})
apps_json = json.dumps(apps)
return '''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Applications - Homelab Status Monitor v''' + VERSION + '''</title>
<style>
:root {
--bg: #0b0f19;
--surface: rgba(30, 41, 59, 0.65);
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #60a5fa;
--accent-2: #818cf8;
}
* { box-sizing: border-box; }
body {
font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
background: radial-gradient(circle at top left, #172033 0%, var(--bg) 40%), var(--bg);
color: var(--text);
margin: 0; padding: 24px; min-height: 100vh;
}
.container { max-width: 900px; margin: 0 auto; }
header { text-align: center; margin-bottom: 24px; }
h1 {
font-size: 2.2em; font-weight: 800; margin: 0;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.back { display: inline-block; margin-bottom: 20px; color: var(--accent); text-decoration: none; }
.back:hover { text-decoration: underline; }
.hint { color: var(--muted); font-size: 0.9em; margin-bottom: 18px; text-align: center; }
.app-list { list-style: none; padding: 0; margin: 0; }
.app-item {
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.15);
border-radius: 16px;
padding: 16px 18px;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 14px;
cursor: grab;
backdrop-filter: blur(14px);
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
transition: transform 0.15s, box-shadow 0.2s, background 0.2s;
}
.app-item:active { cursor: grabbing; }
.app-item.dragging { opacity: 0.5; transform: scale(0.98); }
.app-item:hover { box-shadow: 0 12px 40px rgba(0,0,0,0.25); transform: translateY(-2px); }
.drag-handle {
width: 24px; height: 24px;
display: flex; flex-direction: column; justify-content: center; gap: 4px;
color: var(--muted);
}
.drag-handle span { display: block; height: 2px; background: currentColor; border-radius: 2px; }
.app-name { font-weight: 700; font-size: 1.05em; flex: 1; }
.app-url { color: var(--accent); text-decoration: none; word-break: break-all; }
.app-url:hover { text-decoration: underline; }
</style>
</head>
<body>
<div class="container">
<header>
<a href="/" class="back">&larr; Back to dashboard</a>
<h1>Applications</h1>
</header>
<div class="hint">Drag items to reorder. The order is saved in your browser.</div>
<ul class="app-list" id="app-list"></ul>
</div>
<script>
const APPS = ''' + apps_json + ''';
const STORAGE_KEY = "statusmonitor_apps_order";
function getOrderedApps() {
const saved = localStorage.getItem(STORAGE_KEY);
if (!saved) return APPS.slice();
let order;
try { order = JSON.parse(saved); } catch (e) { return APPS.slice(); }
const map = new Map(APPS.map(a => [a.id, a]));
const ordered = order.map(id => map.get(id)).filter(a => a);
const remaining = APPS.filter(a => !order.includes(a.id));
return ordered.concat(remaining);
}
function saveOrder(order) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(order));
}
function renderList() {
const list = document.getElementById("app-list");
const apps = getOrderedApps();
list.innerHTML = apps.map(a =>
"<li class='app-item' draggable='true' data-id='" + a.id + "'>" +
"<div class='drag-handle'><span></span><span></span><span></span></div>" +
"<div class='app-name'>" + a.name + "</div>" +
"<a class='app-url' href='" + a.url + "' target='_blank' rel='noopener noreferrer'>" + a.url + "</a>" +
"</li>"
).join("");
initDrag();
}
function initDrag() {
const list = document.getElementById("app-list");
let dragged = null;
list.querySelectorAll(".app-item").forEach(item => {
item.addEventListener("dragstart", function(e) {
dragged = item;
item.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
});
}, 30000);
item.addEventListener("dragend", function() {
item.classList.remove("dragging");
dragged = null;
const order = Array.from(list.children).map(li => li.dataset.id);
saveOrder(order);
});
item.addEventListener("dragover", function(e) {
e.preventDefault();
if (!dragged || dragged === item) return;
const rect = item.getBoundingClientRect();
const offset = e.clientY - rect.top - rect.height / 2;
if (offset < 0) list.insertBefore(dragged, item);
else list.insertBefore(dragged, item.nextSibling);
});
});
}
renderList();
</script>
</body>
</html>'''
@@ -417,7 +666,13 @@ class Handler(BaseHTTPRequestHandler):
self.send_header('Content-type', 'text/html; charset=utf-8')
self.end_headers()
self.wfile.write(generate_html().encode('utf-8'))
elif self.path in ['/apps', '/apps.html']:
self.send_response(200)
self.send_header('Content-type', 'text/html; charset=utf-8')
self.end_headers()
self.wfile.write(generate_apps_html().encode('utf-8'))
elif self.path == '/api/status':
check_all() # Always refresh cache
self.send_response(200)