feat(status-monitor): v1.2.0 - Liens cliquables, filtres, public_url
This commit is contained in:
+306
-310
@@ -1,310 +1,306 @@
|
||||
# Status Monitor - Homelab
|
||||
|
||||
**Application de monitoring des services Homelab**
|
||||
**Version** : 1.1.0
|
||||
**Auteur** : Mistral Vibe
|
||||
**Dernière mise à jour** : 19/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
|
||||
- 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 (via CT200)
|
||||
|
||||
```bash
|
||||
# Copier les fichiers modifiés vers CT200
|
||||
scp -i C:\Users\theo\.ssh\id_ed25519 app.py root@192.168.0.100:/opt/git-repos/homelab-config/status-monitor/
|
||||
|
||||
# Sur CT200 :
|
||||
ssh -i C:\Users\theo\.ssh\id_ed25519 root@192.168.0.100
|
||||
cd /opt/git-repos/homelab-config
|
||||
|
||||
# Vérifier les changements
|
||||
git status
|
||||
git diff status-monitor/
|
||||
|
||||
# Ajouter, commiter, pousser
|
||||
git add status-monitor/
|
||||
git commit -m "Update Status Monitor - <description des changements>
|
||||
|
||||
Generated by Mistral Vibe.
|
||||
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### 3. Déployer sur CT201
|
||||
|
||||
```bash
|
||||
# Méthode A : Exécuter le script de déploiement
|
||||
bash /opt/git-repos/homelab-config/status-monitor/deploy.sh
|
||||
|
||||
# Méthode B : Manuellement
|
||||
ssh root@192.168.0.201
|
||||
cd /opt/status-monitor
|
||||
|
||||
# Cloner ou pull le dépôt
|
||||
if [ ! -d "homelab-config" ]; then
|
||||
git clone https://VisualPCI:3N14E7GWVSgi3K@git.peis.fr/VisualPCI/homelab-config.git
|
||||
else
|
||||
cd homelab-config
|
||||
git pull
|
||||
cd ..
|
||||
fi
|
||||
|
||||
# Copier les fichiers
|
||||
cp -f homelab-config/status-monitor/app.py .
|
||||
cp -f homelab-config/status-monitor/status-monitor.service /etc/systemd/system/
|
||||
|
||||
# Redémarrer
|
||||
systemctl daemon-reload
|
||||
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),
|
||||
"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
|
||||
- `https` : Vérifie la réponse HTTPS sur un port
|
||||
|
||||
---
|
||||
|
||||
## 🎨 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** | VisualPCI |
|
||||
| **Token Git** | 3N14E7GWVSgi3K |
|
||||
| **Serveur de déploiement** | CT201 (192.168.0.201) |
|
||||
| **URL publique** | https://status.peis.fr |
|
||||
| **Port** | 80 |
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Prochaines Évolutions
|
||||
|
||||
- [ ] Ajouter l'authentification basique
|
||||
- [ ] Ajouter des notifications (email, webhook)
|
||||
- [ ] Intégrer avec Grafana/Prometheus
|
||||
- [ ] Historique persistant en base de données
|
||||
- [ ] Dashboard personnalisable
|
||||
|
||||
---
|
||||
|
||||
*Documentation générée par Mistral Vibe - 19/07/2026*
|
||||
# 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*
|
||||
|
||||
+475
-428
@@ -1,428 +1,475 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Homelab Status Monitor v1.1.1"""
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
import urllib.request
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
VERSION = "1.1.1"
|
||||
|
||||
# Service configuration: name, type (ssh/http/https), host, port, url (for http/https), insecure (for https)
|
||||
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": "/"},
|
||||
|
||||
"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": "/", "insecure": True},
|
||||
|
||||
"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": "/"},
|
||||
|
||||
"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": "/"},
|
||||
|
||||
# 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": "/"},
|
||||
|
||||
# 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": "/", "insecure": True},
|
||||
"traefik-dash": {"name": "traefik.peis.fr", "type": "https", "host": "traefik.peis.fr", "port": 443, "url": "/dashboard/", "insecure": True},
|
||||
"test-peis-fr": {"name": "test.peis.fr", "type": "https", "host": "test.peis.fr", "port": 443, "url": "/", "insecure": True},
|
||||
"status-peis-fr": {"name": "status.peis.fr", "type": "https", "host": "status.peis.fr", "port": 443, "url": "/", "insecure": True},
|
||||
}
|
||||
|
||||
status_history = {}
|
||||
status_cache = {}
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
def check_ssh(host, port, timeout=3):
|
||||
"""Check if SSH port is open."""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
result = sock.connect_ex((host, port))
|
||||
sock.close()
|
||||
if result == 0:
|
||||
return {"status": "online", "error": None}
|
||||
else:
|
||||
return {"status": "offline", "error": f"Connection refused (code: {result})"}
|
||||
except socket.timeout:
|
||||
return {"status": "timeout", "error": "Connection timed out"}
|
||||
except Exception as e:
|
||||
return {"status": "offline", "error": str(e)[:100]}
|
||||
|
||||
|
||||
def check_http(host, port, url="/", timeout=5):
|
||||
"""Check HTTP endpoint."""
|
||||
try:
|
||||
full_url = f"http://{host}:{port}{url}"
|
||||
req = urllib.request.Request(full_url, headers={"User-Agent": "StatusMon"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return {"status": "online", "status_code": r.status, "error": None}
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"status": "online", "status_code": e.code, "error": None} # HTTP error still means service is up
|
||||
except Exception as e:
|
||||
return {"status": "offline", "error": str(e)[:100]}
|
||||
|
||||
|
||||
def check_https(host, port, url="/", timeout=5, insecure=False):
|
||||
"""Check HTTPS endpoint."""
|
||||
try:
|
||||
full_url = f"https://{host}:{port}{url}"
|
||||
req = urllib.request.Request(full_url, headers={"User-Agent": "StatusMon"})
|
||||
if insecure:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
else:
|
||||
ctx = ssl.create_default_context()
|
||||
with urllib.request.urlopen(req, context=ctx, timeout=timeout) as r:
|
||||
return {"status": "online", "status_code": r.status, "error": None}
|
||||
except ssl.SSLError as e:
|
||||
return {"status": "ssl_error", "error": f"SSL: {str(e)[:80]}"}
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"status": "online", "status_code": e.code, "error": None}
|
||||
except Exception as e:
|
||||
return {"status": "offline", "error": str(e)[:100]}
|
||||
|
||||
|
||||
def check_service(service_id, config):
|
||||
"""Check a single service based on its type."""
|
||||
st = config["type"]
|
||||
if st == "ssh":
|
||||
return check_ssh(config["host"], config["port"])
|
||||
elif st == "http":
|
||||
return check_http(config["host"], config["port"], config.get("url", "/"))
|
||||
elif st == "https":
|
||||
return check_https(config["host"], config["port"], config.get("url", "/"), config.get("insecure", False))
|
||||
return {"status": "error", "error": f"Unknown type: {st}"}
|
||||
|
||||
|
||||
def update_history(sid, old, new):
|
||||
"""Update status change history."""
|
||||
global status_history
|
||||
if old != new:
|
||||
status_history[sid] = {
|
||||
"old_status": old,
|
||||
"new_status": new,
|
||||
"changed_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
|
||||
def check_single(sid):
|
||||
"""Check a single service and return its status."""
|
||||
if sid not in SERVICES:
|
||||
return None
|
||||
result = check_service(sid, SERVICES[sid])
|
||||
return {"config": SERVICES[sid], "result": result, "time": datetime.now().isoformat()}
|
||||
|
||||
|
||||
def check_all():
|
||||
"""Check all services and update cache."""
|
||||
global status_cache, status_history
|
||||
new_cache = {}
|
||||
for sid, cfg in SERVICES.items():
|
||||
result = check_service(sid, cfg)
|
||||
old = status_cache.get(sid, {}).get("result", {}).get("status", "unknown")
|
||||
new = result["status"]
|
||||
if old != new:
|
||||
update_history(sid, old, new)
|
||||
new_cache[sid] = {"config": cfg, "result": result, "time": datetime.now().isoformat()}
|
||||
with lock:
|
||||
status_cache = new_cache
|
||||
return new_cache
|
||||
|
||||
|
||||
# HTML Template (using a function to avoid f-string escaping issues)
|
||||
def generate_html():
|
||||
"""Generate the HTML page."""
|
||||
return '''<!DOCTYPE html>
|
||||
<html>
|
||||
<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; }
|
||||
.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; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Homelab Status Monitor</h1>
|
||||
<div class="version">v''' + VERSION + '''</div>
|
||||
<div class="controls">
|
||||
<button class="refresh-all" onclick="refreshAll()">Refresh All</button>
|
||||
<button onclick="location.reload()">Reload Page</button>
|
||||
<span id="ts"></span>
|
||||
<span id="global-loading"></span>
|
||||
</div>
|
||||
<div class="summary">
|
||||
<div class="card-sum"><div class="count" id="total">0</div><div class="label">Total</div></div>
|
||||
<div class="card-sum"><div class="count" id="ok">0</div><div class="label">Online</div></div>
|
||||
<div class="card-sum"><div class="count" id="ko">0</div><div class="label">Offline</div></div>
|
||||
<div class="card-sum"><div class="count" id="err">0</div><div class="label">Errors</div></div>
|
||||
</div>
|
||||
<div class="grid" id="grid"></div>
|
||||
</div>
|
||||
<script>
|
||||
let data = {};
|
||||
let history = {};
|
||||
let refreshInProgress = false;
|
||||
|
||||
function getClass(s) {
|
||||
var classes = {"online": "online", "offline": "offline", "timeout": "timeout", "error": "error", "ssl_error": "ssl_error"};
|
||||
return classes[s] || "offline";
|
||||
}
|
||||
|
||||
function fmtHist(sid) {
|
||||
if (history[sid]) {
|
||||
var h = history[sid];
|
||||
return "Changed: " + h.old_status + " -> " + h.new_status + " at " + new Date(h.changed_at).toLocaleTimeString();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function setGlobalLoad(b) {
|
||||
refreshInProgress = b;
|
||||
var elem = document.getElementById("global-loading");
|
||||
elem.innerHTML = b ? '<span class="loading-spinner"></span>' : '';
|
||||
}
|
||||
|
||||
function render() {
|
||||
let h = "";
|
||||
let t = 0, ok = 0, ko = 0, err = 0;
|
||||
for (const [id, v] of Object.entries(data)) {
|
||||
const c = v.config, r = v.result, s = r.status;
|
||||
t++;
|
||||
if (s === "online") ok++;
|
||||
else if (s === "offline" || s === "error" || s === "timeout" || s === "ssl_error") ko++;
|
||||
else err++;
|
||||
|
||||
const bc = getClass(s);
|
||||
const pi = c.url ? c.host + ":" + c.port + c.url : c.host + ":" + c.port;
|
||||
|
||||
h += "<div class='card ' + bc + '>";
|
||||
h += "<button class='refresh-btn' onclick='refreshService(\'" + id + "\')' title='Refresh'>🔄</button>";
|
||||
h += "<div class='header'><div class='name'>" + c.name + "</div><span class='type'>" + c.type + "</span></div>";
|
||||
h += "<span class='badge " + bc + "'>" + s.replace(/_/g, " ") + "</span>";
|
||||
h += "<div class='details'>" + pi + "</div>";
|
||||
if (r.error) 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>";
|
||||
const hist = fmtHist(id);
|
||||
if (hist) h += "<div class='history-note'>" + hist + "</div>";
|
||||
h += "</div>";
|
||||
}
|
||||
|
||||
document.getElementById("grid").innerHTML = h || "<p style='color:#64748b;text-align:center;'>No services to display</p>";
|
||||
document.getElementById("total").textContent = t;
|
||||
document.getElementById("ok").textContent = ok;
|
||||
document.getElementById("ko").textContent = ko;
|
||||
document.getElementById("err").textContent = err;
|
||||
document.getElementById("ts").textContent = "Updated: " + new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
function refreshService(sid) {
|
||||
const btn = event.target;
|
||||
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();
|
||||
})
|
||||
.catch(err => console.error("Error:", err))
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = "🔄";
|
||||
});
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
const btn = document.querySelector(".refresh-all");
|
||||
setGlobalLoad(true);
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="loading-spinner"></span> Refreshing...';
|
||||
fetch("/api/refresh")
|
||||
.then(r => r.json())
|
||||
.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";
|
||||
});
|
||||
}
|
||||
|
||||
// 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() {
|
||||
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);
|
||||
});
|
||||
}, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
"""HTTP request handler."""
|
||||
|
||||
def log_message(self, *args):
|
||||
"""Suppress default logging."""
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
"""Handle GET requests."""
|
||||
try:
|
||||
if self.path in ['/', '/index.html']:
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'text/html; charset=utf-8')
|
||||
self.end_headers()
|
||||
self.wfile.write(generate_html().encode('utf-8'))
|
||||
|
||||
elif self.path == '/api/status':
|
||||
check_all() # Always refresh cache
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
self.send_header('Cache-Control', 'no-cache')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"data": status_cache, "history": status_history}, default=str).encode('utf-8'))
|
||||
|
||||
elif self.path == '/api/refresh':
|
||||
result = check_all()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"data": result, "history": status_history}, default=str).encode('utf-8'))
|
||||
|
||||
elif self.path.startswith('/api/check/'):
|
||||
sid = self.path.split('/')[-1]
|
||||
if sid in SERVICES:
|
||||
old = status_cache.get(sid, {}).get("result", {}).get("status", "unknown")
|
||||
rd = check_single(sid)
|
||||
new_st = rd["result"]["status"]
|
||||
if old != new_st:
|
||||
update_history(sid, old, new_st)
|
||||
with lock:
|
||||
status_cache[sid] = rd
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({**rd, "history": status_history.get(sid)}, default=str).encode('utf-8'))
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
except Exception as e:
|
||||
self.send_response(500)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
print(f"Starting Status Monitor v{VERSION}")
|
||||
check_all()
|
||||
server = ThreadingHTTPServer(('', 80), Handler)
|
||||
print(f'Status Monitor v{VERSION} running on port 80')
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
#!/usr/bin/env python3
|
||||
"""Homelab Status Monitor v1.2.0"""
|
||||
|
||||
import socket
|
||||
import ssl
|
||||
import urllib.request
|
||||
import json
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
VERSION = "1.2.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)
|
||||
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},
|
||||
|
||||
# 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},
|
||||
|
||||
# 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},
|
||||
|
||||
# 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},
|
||||
}
|
||||
|
||||
status_history = {}
|
||||
status_cache = {}
|
||||
lock = threading.Lock()
|
||||
|
||||
|
||||
def check_ssh(host, port, timeout=3):
|
||||
"""Check if SSH port is open."""
|
||||
try:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
result = sock.connect_ex((host, port))
|
||||
sock.close()
|
||||
if result == 0:
|
||||
return {"status": "online", "error": None}
|
||||
else:
|
||||
return {"status": "offline", "error": f"Connection refused (code: {result})"}
|
||||
except socket.timeout:
|
||||
return {"status": "timeout", "error": "Connection timed out"}
|
||||
except Exception as e:
|
||||
return {"status": "offline", "error": str(e)[:100]}
|
||||
|
||||
|
||||
def check_http(host, port, url="/", timeout=5):
|
||||
"""Check HTTP endpoint."""
|
||||
try:
|
||||
full_url = f"http://{host}:{port}{url}"
|
||||
req = urllib.request.Request(full_url, headers={"User-Agent": "StatusMon"})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||
return {"status": "online", "status_code": r.status, "error": None}
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"status": "online", "status_code": e.code, "error": None} # HTTP error still means service is up
|
||||
except Exception as e:
|
||||
return {"status": "offline", "error": str(e)[:100]}
|
||||
|
||||
|
||||
def check_https(host, port, url="/", timeout=5, insecure=False):
|
||||
"""Check HTTPS endpoint."""
|
||||
try:
|
||||
full_url = f"https://{host}:{port}{url}"
|
||||
req = urllib.request.Request(full_url, headers={"User-Agent": "StatusMon"})
|
||||
if insecure:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
else:
|
||||
ctx = ssl.create_default_context()
|
||||
with urllib.request.urlopen(req, context=ctx, timeout=timeout) as r:
|
||||
return {"status": "online", "status_code": r.status, "error": None}
|
||||
except ssl.SSLError as e:
|
||||
return {"status": "ssl_error", "error": f"SSL: {str(e)[:80]}"}
|
||||
except urllib.error.HTTPError as e:
|
||||
return {"status": "online", "status_code": e.code, "error": None}
|
||||
except Exception as e:
|
||||
return {"status": "offline", "error": str(e)[:100]}
|
||||
|
||||
|
||||
def check_service(service_id, config):
|
||||
"""Check a single service based on its type."""
|
||||
st = config["type"]
|
||||
if st == "ssh":
|
||||
return check_ssh(config["host"], config["port"])
|
||||
elif st == "http":
|
||||
return check_http(config["host"], config["port"], config.get("url", "/"))
|
||||
elif st == "https":
|
||||
return check_https(config["host"], config["port"], config.get("url", "/"), config.get("insecure", False))
|
||||
return {"status": "error", "error": f"Unknown type: {st}"}
|
||||
|
||||
|
||||
def update_history(sid, old, new):
|
||||
"""Update status change history."""
|
||||
global status_history
|
||||
if old != new:
|
||||
status_history[sid] = {
|
||||
"old_status": old,
|
||||
"new_status": new,
|
||||
"changed_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
|
||||
def check_single(sid):
|
||||
"""Check a single service and return its status."""
|
||||
if sid not in SERVICES:
|
||||
return None
|
||||
result = check_service(sid, SERVICES[sid])
|
||||
return {"config": SERVICES[sid], "result": result, "time": datetime.now().isoformat()}
|
||||
|
||||
|
||||
def check_all():
|
||||
"""Check all services and update cache."""
|
||||
global status_cache, status_history
|
||||
new_cache = {}
|
||||
for sid, cfg in SERVICES.items():
|
||||
result = check_service(sid, cfg)
|
||||
old = status_cache.get(sid, {}).get("result", {}).get("status", "unknown")
|
||||
new = result["status"]
|
||||
if old != new:
|
||||
update_history(sid, old, new)
|
||||
new_cache[sid] = {"config": cfg, "result": result, "time": datetime.now().isoformat()}
|
||||
with lock:
|
||||
status_cache = new_cache
|
||||
return new_cache
|
||||
|
||||
|
||||
# HTML Template (using a function to avoid f-string escaping issues)
|
||||
def generate_html():
|
||||
"""Generate the HTML page."""
|
||||
return '''<!DOCTYPE html>
|
||||
<html>
|
||||
<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; }
|
||||
.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; }
|
||||
.details a:hover { text-decoration: underline; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Homelab Status Monitor</h1>
|
||||
<div class="version">v''' + VERSION + '''</div>
|
||||
<div class="controls">
|
||||
<button class="refresh-all" onclick="refreshAll()">Refresh All</button>
|
||||
<button onclick="location.reload()">Reload Page</button>
|
||||
<span id="ts"></span>
|
||||
<span id="global-loading"></span>
|
||||
</div>
|
||||
<div class="filters" id="filters">
|
||||
<button class="filter-btn active" data-filter="all" onclick="setFilter('all')">All</button>
|
||||
<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="others" onclick="setFilter('others')">Others</button>
|
||||
</div>
|
||||
<div class="summary">
|
||||
<div class="card-sum"><div class="count" id="total">0</div><div class="label">Total</div></div>
|
||||
<div class="card-sum"><div class="count" id="ok">0</div><div class="label">Online</div></div>
|
||||
<div class="card-sum"><div class="count" id="ko">0</div><div class="label">Offline</div></div>
|
||||
<div class="card-sum"><div class="count" id="err">0</div><div class="label">Errors</div></div>
|
||||
</div>
|
||||
<div class="grid" id="grid"></div>
|
||||
</div>
|
||||
<script>
|
||||
let data = {};
|
||||
let history = {};
|
||||
let refreshInProgress = false;
|
||||
let activeFilter = 'all';
|
||||
|
||||
function getClass(s) {
|
||||
var classes = {"online": "online", "offline": "offline", "timeout": "timeout", "error": "error", "ssl_error": "ssl_error"};
|
||||
return classes[s] || "offline";
|
||||
}
|
||||
|
||||
function fmtHist(sid) {
|
||||
if (history[sid]) {
|
||||
var h = history[sid];
|
||||
return "Changed: " + h.old_status + " -> " + h.new_status + " at " + new Date(h.changed_at).toLocaleTimeString();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function setGlobalLoad(b) {
|
||||
refreshInProgress = b;
|
||||
var elem = document.getElementById("global-loading");
|
||||
elem.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);
|
||||
});
|
||||
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>";
|
||||
}
|
||||
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';
|
||||
return c.type === activeFilter;
|
||||
}
|
||||
|
||||
function render() {
|
||||
let h = "";
|
||||
let t = 0, ok = 0, ko = 0, err = 0;
|
||||
for (const [id, v] of Object.entries(data)) {
|
||||
const c = v.config, r = v.result, s = r.status;
|
||||
if (!matchesFilter(c)) continue;
|
||||
t++;
|
||||
if (s === "online") ok++;
|
||||
else if (s === "offline" || s === "error" || s === "timeout" || s === "ssl_error") ko++;
|
||||
else err++;
|
||||
|
||||
const bc = getClass(s);
|
||||
const link = buildLink(c);
|
||||
|
||||
h += "<div class='card ' + bc + ' data-type='" + c.type + "'>";
|
||||
h += "<button class='refresh-btn' onclick='refreshService(\'" + id + "\')' title='Refresh'>🔄</button>";
|
||||
h += "<div class='header'><div class='name'>" + c.name + "</div><span class='type'>" + c.type + "</span></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.status_code) h += "<div class='details'>Status: " + r.status_code + "</div>";
|
||||
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>";
|
||||
}
|
||||
|
||||
document.getElementById("grid").innerHTML = h || "<p style='color:#64748b;text-align:center;'>No services to display for this filter</p>";
|
||||
document.getElementById("total").textContent = t;
|
||||
document.getElementById("ok").textContent = ok;
|
||||
document.getElementById("ko").textContent = ko;
|
||||
document.getElementById("err").textContent = err;
|
||||
document.getElementById("ts").textContent = "Updated: " + new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
function refreshService(sid) {
|
||||
const btn = event.target;
|
||||
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();
|
||||
})
|
||||
.catch(err => console.error("Error:", err))
|
||||
.finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = "🔄";
|
||||
});
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
const btn = document.querySelector(".refresh-all");
|
||||
setGlobalLoad(true);
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<span class="loading-spinner"></span> Refreshing...';
|
||||
fetch("/api/refresh")
|
||||
.then(r => r.json())
|
||||
.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";
|
||||
});
|
||||
}
|
||||
|
||||
// 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() {
|
||||
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);
|
||||
});
|
||||
}, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
"""HTTP request handler."""
|
||||
|
||||
def log_message(self, *args):
|
||||
"""Suppress default logging."""
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
"""Handle GET requests."""
|
||||
try:
|
||||
if self.path in ['/', '/index.html']:
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'text/html; charset=utf-8')
|
||||
self.end_headers()
|
||||
self.wfile.write(generate_html().encode('utf-8'))
|
||||
|
||||
elif self.path == '/api/status':
|
||||
check_all() # Always refresh cache
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
self.send_header('Cache-Control', 'no-cache')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"data": status_cache, "history": status_history}, default=str).encode('utf-8'))
|
||||
|
||||
elif self.path == '/api/refresh':
|
||||
result = check_all()
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"data": result, "history": status_history}, default=str).encode('utf-8'))
|
||||
|
||||
elif self.path.startswith('/api/check/'):
|
||||
sid = self.path.split('/')[-1]
|
||||
if sid in SERVICES:
|
||||
old = status_cache.get(sid, {}).get("result", {}).get("status", "unknown")
|
||||
rd = check_single(sid)
|
||||
new_st = rd["result"]["status"]
|
||||
if old != new_st:
|
||||
update_history(sid, old, new_st)
|
||||
with lock:
|
||||
status_cache[sid] = rd
|
||||
self.send_response(200)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({**rd, "history": status_history.get(sid)}, default=str).encode('utf-8'))
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
else:
|
||||
self.send_response(404)
|
||||
self.end_headers()
|
||||
|
||||
except Exception as e:
|
||||
self.send_response(500)
|
||||
self.send_header('Content-type', 'application/json')
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point."""
|
||||
print(f"Starting Status Monitor v{VERSION}")
|
||||
check_all()
|
||||
server = ThreadingHTTPServer(('', 80), Handler)
|
||||
print(f'Status Monitor v{VERSION} running on port 80')
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
Reference in New Issue
Block a user