39 Commits

Author SHA1 Message Date
Gilles Soulier 5ee8b66464 feat(v0.1.7): port iperf3 configurable + iperf3 docker sur port 5202
- config.toml: nouveau champ [server] iperf3_port (défaut 5201)
- network_info: iperf3 -p <port> utilise le port configuré
- docker-compose: iperf3 exposé sur 5202 (5201 occupé par linux_benchtools)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:47:15 +02:00
Gilles Soulier c238e9f2b8 fix: supprimer service iperf3 — port 5201 déjà occupé par linux_benchtools
Un container iperf3 (linux_benchtools_iperf3) tourne depuis 4 mois sur le
même hôte. L'agent se connecte à l'IP du serveur:5201 qui résout vers ce
container existant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:43:06 +02:00
Gilles Soulier d7fe0004ad fix: iperf3 — build depuis Alpine ECR au lieu d'image communautaire Docker Hub
networkstatic/iperf3 n'est pas disponible sur ECR public (images officielles seulement).
Solution : Dockerfile.iperf3 basé sur alpine:latest + apk add iperf3.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:25:23 +02:00
Gilles Soulier 0247cfaada chore: binaire agent v0.1.6 linux-arm64
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:24:19 +02:00
Gilles Soulier dcfba242d6 chore: binaire agent v0.1.6 linux-amd64
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:22:56 +02:00
Gilles Soulier ff6cf1cd5e feat: métriques réseau+hardware serveur+dashboard + API /agents/{id} + iperf3
Serveur:
- Modèles Go: NetworkInterface, HardwareInfo dans Agent + AgentMetrics
- DB: migrations network_info_json + hardware_info_json dans agents
- UpsertAgent: stocke les données lentes si présentes dans le payload
- GetAgents: désérialise network_info_json + hardware_info_json
- GET /api/agents/{id}: endpoint single agent
- docker-compose: service iperf3 (port 5201)

Dashboard:
- Popup détail: section RÉSEAU (tableau interfaces: type, vitesse, MAC, WoL, iperf3)
- Popup détail: section HARDWARE (carte mère, CPU, RAM slots/type/vitesse)
- CSS: .net-table/.net-row pour le tableau réseau
- Font-size global appliqué sur html root (au lieu de body)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:17:54 +02:00
Gilles Soulier 0430c0f2a8 feat(agent v0.1.6): métriques réseau enrichies + hardware dmidecode
- Nouveaux types payload: NetworkInterface, HardwareInfo
- Config: slow_daily_time (HH:MM), network_info, hardware_info
- Module network_info: interfaces locales, type ETH/WIFI, speed, MAC, WoL, iperf3
- Module hardware: dmidecode (carte mère, CPU, slots RAM, type/vitesse)
- Scheduler: collecte au démarrage + 1×/jour à l'heure configurée
- install.sh: ajout iperf3, dmidecode dans paquets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:16:02 +02:00
Gilles Soulier 49626ddb9e feat: RAM en Go dans popup + version agent dans install.sh
- Popup détail : valeur absolue RAM (Go) affichée à côté du %
- install.sh : bannière version agent plus visible à la fin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 06:10:55 +02:00
Gilles Soulier f93f5741da feat: badges SMART pills, versionning serveur, fix copier HTTP
- Dashboard: icônes SMART → pills OK/USAGÉ/PREFAIL/HS cliquables
  (tuile + popup détail + popup SMART redessiné pour novices)
- Serveur: constante version 0.1.0 exposée via WS server_stats → footer
- Fix copier script install en HTTP (isSecureContext avant clipboard API)
- install.sh: ajout ethtool, suppression logique OVERWRITE_CONFIG

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 05:56:44 +02:00
Gilles Soulier 982483e0bf chore: binaires v0.1.5 + registry ECR public pour Docker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 05:56:36 +02:00
Gilles Soulier a53923fd8e feat(v0.1.5): SMART multi-disques — collecte tous les disques détectés
Agent:
- SmartMetrics + champ device (nom du disque ex: sda, nvme0)
- smart: Option<Vec<SmartMetrics>> — tous les disques, pas seulement le 1er
- collect() itère /sys/block, accumule les résultats de tous les disques valides

Serveur:
- SmartMetrics.Device + Smart []SmartMetrics dans AgentMetrics
- InsertMetrics: stocke smart_json (JSON array) au lieu de colonnes plates
- GetLastMetrics: désérialise smart_json
- Migration: smart_json TEXT ajoutée

Dashboard:
- Tuile: une icône shield/triangle par disque avec tooltip incluant le nom
- Popup détail: un bouton SMART par disque (couleur ok/err)
- showSmart(agentId, diskIdx): affiche le disque sélectionné

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 05:23:23 +02:00
Gilles Soulier 1b9daae08a fix: migrations smart_* manquantes dans la table metrics
La table metrics existant avant l'ajout du SMART n'avait pas les colonnes
smart_passed/temp/realloc/hours/wear. CREATE TABLE IF NOT EXISTS ne les ajoute
pas rétroactivement — les INSERT échouaient silencieusement, data ignorée.

ALTER TABLE ... ADD COLUMN est idempotent (erreur ignorée si colonne existante).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 05:16:21 +02:00
Gilles Soulier fdf76477e5 feat: type de jauge configurable (compact / standard)
- ServerConfig: champ gauge_type (défaut "compact")
- CSS: classes .gs-* pour la BatteryGauge standard (label + bar 9px + gloss interne)
- Grid: helper renderGaugeRow() — sélectionne compact ou standard selon la config
- Grid: rerenderAll() pour appliquer le changement sans recharger la page
- Popup config serveur: select "Type de jauge" dans la section Affichage des tuiles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 05:10:28 +02:00
Gilles Soulier 22b429f247 feat: dashboard dynamique + RAM min/max dans popup
- Grid: nouvel agent ajouté en temps réel dès le 1er paquet WebSocket (plus besoin d'actualiser la page)
- Grid: ip/status mis à jour depuis chaque metrics_update (adresse DHCP fraîche)
- WS: diffuse agent_removed lors de la suppression d'un agent (sync multi-onglets)
- Popup détail: min/max RAM sur la période affichée (calculé depuis l'historique déjà chargé)
- CSS: classe .chart-minmax pour l'affichage min/max sous le graphe

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:53:05 +02:00
Gilles Soulier a2060a1713 feat(v0.1.4): SMART tile icon, IP locale robuste, copier HTTP, nettoyage UI
- Agent: détection IP via server_ip en priorité (fallback 8.8.8.8) — résout 0.0.0.0 sur LAN sans internet
- Agent: détection auto des disques /sys/block (sd*, nvme*) + fix continue dans la boucle smartctl
- Agent: SupplementaryGroups=disk dans le service systemd pour accès smartctl
- Dashboard: icône SMART (shield-check/triangle-exclamation) dans la ligne disque de la tuile
- Dashboard: bouton Copier compatible HTTP (fallback execCommand si clipboard API indisponible)
- Dashboard: suppression du texte redondant dans la section INSTALLATION AGENT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:46:52 +02:00
Gilles Soulier e65770407c chore(agent): bump version 0.1.2 → 0.1.3 2026-05-22 22:35:05 +02:00
Gilles Soulier 9e77d961f5 feat(agent): déconnexion propre sur SIGTERM/SIGINT
- Capture SIGTERM et SIGINT via libc::signal → AtomicBool RUNNING
- La boucle principale s'arrête proprement à la prochaine itération
- Envoi d'un paquet status:offline via UDP avant de quitter
- MQTT : publish status offline + disconnect() pour déconnexion gracieuse
  (le last_will reste actif pour les déconnexions brutales)
- payload.rs: #[serde(default)] sur version pour compatibilité descendante

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:34:55 +02:00
Gilles Soulier 3933301cff fix(db): GetLastMetrics retourne la dernière valeur non-nulle par colonne
La requête précédente prenait la dernière ligne (paquet rapide, 2s) qui
a hdd_*/smart_* à NULL. Chaque sous-requête cible maintenant la dernière
valeur non-nulle indépendamment, ce qui restitue les données disque/smart
au rechargement même si le dernier paquet ne les contenait pas.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:29:06 +02:00
Gilles Soulier 9f87c9294d revert(docker): retour au multi-stage, docker login requis pour le pull
Dockerfile multi-stage (golang:1.22-alpine → scratch) pour un build
autonome. docker-compose sans version obsolète, pull:false pour le
builder, pull_policy:if_not_present pour nginx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:12:56 +02:00
Gilles Soulier 638d347bb0 fix(docker): évite les pulls Docker Hub inutiles (rate limit 429)
- Retire l'attribut version obsolète
- build.pull: false — BuildKit ne vérifie plus le manifest pour golang:1.22-alpine
- pull_policy: if_not_present — nginx:alpine n'est tiré que si absent du cache

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:06:41 +02:00
Gilles Soulier 8f3dbd0532 3 2026-05-22 22:06:12 +02:00
Gilles Soulier 99bdf79a63 fix(docker): remplace alpine:3.19 par scratch pour éviter le rate limit
Le binaire est statique (CGO_ENABLED=0) — scratch suffit. Seuls les
certificats TLS sont copiés depuis le builder golang:1.22-alpine.
Élimine le pull de docker.io/library/alpine qui déclenche le 429.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:03:51 +02:00
Gilles Soulier a22d1f4cd2 fix(tile): icône personnalisée masque le fallback FA au chargement
Le span de fallback (fa-server) démarrait en display:flex — visible en
permanence derrière l'image. Il passe à display:none et n'est affiché
que si l'img déclenche onerror (pas d'icône).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:02:11 +02:00
Gilles Soulier d8f395cb53 feat(dashboard): métriques chargées immédiatement au rechargement de page
GET /api/agents inclut désormais last_metrics (dernière ligne de la table
metrics) pour chaque agent. grid.js l'utilise lors du refresh initial, ce
qui peuple les tuiles sans attendre le prochain message WebSocket.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 22:00:33 +02:00
Gilles Soulier f69c22039b fix(icon): upload d'icône — retour d'erreur, WEBP, limite Nginx
- nginx: client_max_body_size 10m (limite par défaut 1 Mo bloquait les images)
- icons.go: import _ golang.org/x/image/webp et image/gif pour décoder WEBP/GIF
- index.html: retire SVG de l'accept (serveur le rejette) et corrige le hint
- popups.js: try/catch autour de uploadIcon → message d'erreur visible dans le hint
  pendant 4s si l'upload échoue ; reset du file input pour re-sélectionner le même
  fichier ; rafraîchit l'img de la tuile avec cache-busting après succès

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:58:46 +02:00
Gilles Soulier 2bda420728 feat(dashboard): affichage disque en Go utilisé/total comme la RAM
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:51:17 +02:00
Gilles Soulier f604e22f6e fix(deploy): permissions config et prompt d'écrasement au réinstall
- ConfigurationDirectoryMode 0750→0755 : le DynamicUser (sans groupe root)
  peut maintenant traverser /etc/nanometrics et lire config.toml
- chmod 644 systématique sur config.toml même si conservé (corrige les
  anciennes installs en 640 qui causent un PermissionDenied au démarrage)
- Prompt interactif si config existe : o=écraser, N=conserver ; variable
  OVERWRITE_CONFIG=true pour forcer sans interaction (curl|bash)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 21:49:13 +02:00
Gilles Soulier 8d4dc0e853 fix(deploy): arrêt du service avant remplacement du binaire
Évite l'erreur "Fichier texte occupé" lors d'une mise à jour à chaud.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:32:17 +02:00
Gilles Soulier 311bdbc66d chore: version agent 0.1.2 2026-05-22 20:28:30 +02:00
Gilles Soulier 0df716b8b0 feat: version agent remontée au serveur et affichée dans la popup
- payload.rs : champ version (env!("CARGO_PKG_VERSION"))
- models.go  : Version dans AgentMetrics et Agent
- db.go      : colonne version dans agents + migration ALTER TABLE
- popups.js  : badge version dans la section INFORMATIONS

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:27:26 +02:00
Gilles Soulier 243c97d71b fix: disque via statvfs() — valeurs identiques à df
Remplace sysinfo::Disks par un appel direct à libc::statvfs("/").
- used  = (f_blocks − f_bfree) × f_frsize  → correspond à df "Utilisé"
- free  = f_bavail × f_frsize              → correspond à df "Dispo"
- total = f_blocks × f_frsize

Avant (sysinfo) : used comptait les blocs réservés root → surestimation de ~3-4 Go.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:21:58 +02:00
Gilles Soulier e0ed96309c fix: conserver les métriques lentes (disque, smart) entre les paquets
Le disque est envoyé toutes les 60s mais les paquets arrivent toutes les 2s.
Chaque nouveau paquet écrasait les champs null, effaçant le disque affiché.
Correction : fusion avec les anciennes métriques, null ne remplace pas une valeur.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:14:45 +02:00
Gilles Soulier 93747e4a04 feat: favicons + correctifs tuile (RAM overflow, corbeille droite)
Favicons :
- favicon.svg (scalable, navigateurs modernes)
- favicon.ico (16/32/48px, compatibilité universelle)
- favicon-{16,32,48,96,180,192,512}.png
- favicon-180.png pour apple-touch-icon
- site.webmanifest pour PWA / ajout écran d'accueil Android
- Couleurs Gruvbox : fond #282828, accent orange, LED verte

Tuile :
- g-val : min-width + white-space:nowrap (RAM 3.0Go/5.8Go ne déborde plus)
- tile-foot : justify-content:space-between + tile-foot-info wrapper
  (corbeille alignée en bas à droite)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:07:40 +02:00
Gilles Soulier b93b55d5a8 fix: RAM déborde de la tuile, corbeille alignée à droite
- g-val : largeur fixe 34px → min-width + white-space:nowrap (RAM "3.0Go/5.8Go")
- tile-foot : justify-content:space-between + wrapper tile-foot-info
  pour que la corbeille soit toujours en bas à droite

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 20:06:21 +02:00
Gilles Soulier 46209b2965 fix: chmod 644 sur config.toml (DynamicUser ne peut pas lire 640)
Avec DynamicUser=yes, le fichier config.toml créé en root:root 640
n'est pas lisible par l'utilisateur dynamique → exit 101 (panic Rust).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:58:02 +02:00
Gilles Soulier 775d54f07c feat: suppression agent, RAM en Go, métriques par défaut (cpu/mem/disk/smart)
- API DELETE /api/agents/{id} — supprime agent + métriques + config + icône
- Bouton poubelle sur chaque tuile + dialog de confirmation
- RAM : affichage "utilisé/total" en Go (ex: 6.2Go/8.0Go) au lieu du %
- Config agent par défaut : cpu, memory, disk, smart activés (UDP)
- DefaultAgentConfig() dans models pour les nouveaux agents

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:54:10 +02:00
Gilles Soulier e9524858f5 feat: commande d'installation agent dans la config serveur
Nouvelle section "INSTALLATION AGENT" en bas du popup de configuration :
champ lecture seule avec la commande curl pré-remplie (SERVER_IP auto
depuis window.location.hostname) + bouton Copier.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:46:52 +02:00
Gilles Soulier 1a1202abcf fix: valeurs par défaut install.sh (serveur 10.0.0.50, MQTT 10.0.0.3)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:42:39 +02:00
Gilles Soulier c526a6e5ca fix: cross-compilation musl pour release multi-arch
- rumqttc : use-native-tls → use-rustls (supprime dépendance OpenSSL)
- .cargo/config.toml (racine) : linkers musl + CC/AR pour ring aarch64
- deploy/release.sh : passe CC_aarch64_unknown_linux_musl au build
- .gitignore : règle config.toml affinée (exclut cargo configs)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:42:08 +02:00
51 changed files with 1314 additions and 320 deletions
+9
View File
@@ -0,0 +1,9 @@
[target.x86_64-unknown-linux-musl]
linker = "musl-gcc"
[target.aarch64-unknown-linux-musl]
linker = "aarch64-linux-gnu-gcc"
[env]
CC_aarch64_unknown_linux_musl = "aarch64-linux-gnu-gcc"
AR_aarch64_unknown_linux_musl = "aarch64-linux-gnu-ar"
+7 -1
View File
@@ -10,4 +10,10 @@ server/*.test
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
config.toml # Config locaux (contiennent des IPs/secrets)
agent/config.toml
server/config.toml
# Sauf les configs cargo (pas de secrets)
!.cargo/config.toml
!agent/.cargo/config.toml
+9
View File
@@ -0,0 +1,9 @@
[target.x86_64-unknown-linux-musl]
linker = "musl-gcc"
[target.aarch64-unknown-linux-musl]
linker = "aarch64-linux-gnu-gcc"
[env]
CC_aarch64_unknown_linux_musl = "aarch64-linux-gnu-gcc"
AR_aarch64_unknown_linux_musl = "aarch64-linux-gnu-ar"
+130 -99
View File
@@ -38,9 +38,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [ dependencies = [
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
@@ -65,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -97,21 +97,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "futures-core" name = "futures-core"
version = "0.3.32" version = "0.3.32"
@@ -142,6 +127,17 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "getrandom"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.2" version = "0.4.2"
@@ -247,13 +243,14 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
name = "nanometrics-agent" name = "nanometrics-agent"
version = "0.1.0" version = "0.1.6"
dependencies = [ dependencies = [
"libc",
"rumqttc", "rumqttc",
"serde", "serde",
"serde_json", "serde_json",
@@ -262,23 +259,6 @@ dependencies = [
"toml", "toml",
] ]
[[package]]
name = "native-tls"
version = "0.2.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "ntapi" name = "ntapi"
version = "0.4.3" version = "0.4.3"
@@ -294,48 +274,11 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "openssl"
version = "0.10.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
dependencies = [
"bitflags",
"cfg-if",
"foreign-types",
"libc",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "openssl-probe" name = "openssl-probe"
version = "0.2.1" version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.116"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "pin-project-lite" name = "pin-project-lite"
@@ -343,12 +286,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@@ -383,6 +320,20 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "ring"
version = "0.17.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
dependencies = [
"cc",
"cfg-if",
"getrandom 0.2.17",
"libc",
"untrusted",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "rumqttc" name = "rumqttc"
version = "0.24.0" version = "0.24.0"
@@ -393,10 +344,12 @@ dependencies = [
"flume", "flume",
"futures-util", "futures-util",
"log", "log",
"native-tls", "rustls-native-certs",
"rustls-pemfile",
"rustls-webpki",
"thiserror", "thiserror",
"tokio", "tokio",
"tokio-native-tls", "tokio-rustls",
] ]
[[package]] [[package]]
@@ -409,7 +362,63 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys", "windows-sys 0.61.2",
]
[[package]]
name = "rustls"
version = "0.22.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432"
dependencies = [
"log",
"ring",
"rustls-pki-types",
"rustls-webpki",
"subtle",
"zeroize",
]
[[package]]
name = "rustls-native-certs"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5"
dependencies = [
"openssl-probe",
"rustls-pemfile",
"rustls-pki-types",
"schannel",
"security-framework",
]
[[package]]
name = "rustls-pemfile"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
dependencies = [
"zeroize",
]
[[package]]
name = "rustls-webpki"
version = "0.102.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9"
dependencies = [
"ring",
"rustls-pki-types",
"untrusted",
] ]
[[package]] [[package]]
@@ -418,7 +427,7 @@ version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [ dependencies = [
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -429,9 +438,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"core-foundation", "core-foundation",
@@ -527,7 +536,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -539,6 +548,12 @@ dependencies = [
"lock_api", "lock_api",
] ]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.117"
@@ -571,10 +586,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -609,7 +624,7 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -624,12 +639,13 @@ dependencies = [
] ]
[[package]] [[package]]
name = "tokio-native-tls" name = "tokio-rustls"
version = "0.3.1" version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f"
dependencies = [ dependencies = [
"native-tls", "rustls",
"rustls-pki-types",
"tokio", "tokio",
] ]
@@ -687,10 +703,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]] [[package]]
name = "vcpkg" name = "untrusted"
version = "0.2.15" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]] [[package]]
name = "wasi" name = "wasi"
@@ -797,6 +813,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -973,6 +998,12 @@ dependencies = [
"wasmparser", "wasmparser",
] ]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
+3 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "nanometrics-agent" name = "nanometrics-agent"
version = "0.1.0" version = "0.1.7"
edition = "2021" edition = "2021"
[lib] [lib]
@@ -19,10 +19,11 @@ codegen-units = 1
[dependencies] [dependencies]
sysinfo = { version = "0.30", default-features = false } sysinfo = { version = "0.30", default-features = false }
libc = "0.2"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
toml = "0.8" toml = "0.8"
rumqttc = { version = "0.24", default-features = false, features = ["use-native-tls"] } rumqttc = { version = "0.24", default-features = false, features = ["use-rustls"] }
[dev-dependencies] [dev-dependencies]
tempfile = "3" tempfile = "3"
+24
View File
@@ -13,8 +13,12 @@ pub struct Config {
pub struct ServerConfig { pub struct ServerConfig {
pub ip: String, pub ip: String,
pub port: u16, pub port: u16,
#[serde(default = "default_iperf3_port")]
pub iperf3_port: u16,
} }
fn default_iperf3_port() -> u16 { 5201 }
#[derive(Deserialize, Debug, Clone, Default)] #[derive(Deserialize, Debug, Clone, Default)]
pub struct ProtocolsConfig { pub struct ProtocolsConfig {
#[serde(default)] #[serde(default)]
@@ -90,6 +94,26 @@ pub struct MetricsConfig {
pub temperature: MetricProto, pub temperature: MetricProto,
#[serde(default)] #[serde(default)]
pub smart: MetricProto, pub smart: MetricProto,
#[serde(default)]
pub network_info: SlowMetricProto,
#[serde(default)]
pub hardware_info: SlowMetricProto,
#[serde(default = "default_slow_time")]
pub slow_daily_time: String,
}
fn default_slow_time() -> String { "03:00".to_string() }
#[derive(Deserialize, Debug, Clone)]
pub struct SlowMetricProto {
#[serde(default = "default_true")]
pub udp: bool,
#[serde(default)]
pub mqtt: bool,
}
impl Default for SlowMetricProto {
fn default() -> Self { Self { udp: true, mqtt: false } }
} }
#[derive(Deserialize, Debug, Clone, Default)] #[derive(Deserialize, Debug, Clone, Default)]
+93 -11
View File
@@ -1,14 +1,27 @@
use nanometrics_agent::{config, metrics, payload, transport}; use nanometrics_agent::{config, metrics, payload, transport};
use sysinfo::{Components, Disks, Networks, System}; use sysinfo::{Components, Networks, System};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use std::sync::mpsc; use std::sync::mpsc;
use std::sync::atomic::{AtomicBool, Ordering};
fn get_local_ip() -> String { static RUNNING: AtomicBool = AtomicBool::new(true);
extern "C" fn handle_signal(_: libc::c_int) {
RUNNING.store(false, Ordering::Relaxed);
}
fn get_local_ip(server_ip: &str) -> String {
use std::net::UdpSocket; use std::net::UdpSocket;
if let Ok(s) = UdpSocket::bind("0.0.0.0:0") { // Try server IP first (always reachable), then internet fallback
if s.connect("8.8.8.8:80").is_ok() { for target in &[format!("{}:80", server_ip), "8.8.8.8:80".to_string()] {
if let Ok(addr) = s.local_addr() { if let Ok(s) = UdpSocket::bind("0.0.0.0:0") {
return addr.ip().to_string(); if s.connect(target.as_str()).is_ok() {
if let Ok(addr) = s.local_addr() {
let ip = addr.ip().to_string();
if ip != "0.0.0.0" {
return ip;
}
}
} }
} }
} }
@@ -30,11 +43,10 @@ fn main() {
.expect("Impossible de charger config.toml"); .expect("Impossible de charger config.toml");
let hostname = System::host_name().unwrap_or_else(|| "unknown".to_string()); let hostname = System::host_name().unwrap_or_else(|| "unknown".to_string());
let ip = get_local_ip(); let ip = get_local_ip(&cfg.server.ip);
let mut sys = System::new(); let mut sys = System::new();
let mut networks = Networks::new_with_refreshed_list(); let mut networks = Networks::new_with_refreshed_list();
let mut disks = Disks::new_with_refreshed_list();
let mut components = Components::new_with_refreshed_list(); let mut components = Components::new_with_refreshed_list();
let udp_sender = if cfg.protocols.udp.enabled { let udp_sender = if cfg.protocols.udp.enabled {
@@ -50,12 +62,38 @@ fn main() {
None None
}; };
unsafe {
libc::signal(libc::SIGTERM, handle_signal as libc::sighandler_t);
libc::signal(libc::SIGINT, handle_signal as libc::sighandler_t);
}
let mut last_slow = Instant::now(); let mut last_slow = Instant::now();
let mut last_medium = Instant::now(); let mut last_medium = Instant::now();
let mut first_medium = true; let mut first_medium = true;
let mut first_slow = true; let mut first_slow = true;
loop { // Scheduler métriques lentes (startup + 1×/jour à l'heure configurée)
let slow_time: (u32, u32) = {
let parts: Vec<&str> = cfg.metrics.slow_daily_time.splitn(2, ':').collect();
let h = parts.first().and_then(|s| s.parse().ok()).unwrap_or(3u32);
let m = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0u32);
(h, m)
};
let mut slow_daily_done = false;
let mut slow_last_yday = metrics::network_info::current_yday().wrapping_sub(1);
// Collecte immédiate au démarrage
let mut startup_net: Option<Vec<payload::NetworkInterface>> = None;
let mut startup_hw: Option<payload::HardwareInfo> = None;
if cfg.metrics.network_info.udp || cfg.metrics.network_info.mqtt {
let ni = metrics::network_info::collect(&cfg.server.ip, cfg.server.iperf3_port);
if !ni.is_empty() { startup_net = Some(ni); }
}
if cfg.metrics.hardware_info.udp || cfg.metrics.hardware_info.mqtt {
startup_hw = metrics::hardware::collect();
}
while RUNNING.load(Ordering::Relaxed) {
let now = Instant::now(); let now = Instant::now();
while let Ok(transport::mqtt::MqttIncoming::ConfigUpdate(data)) = incoming_rx.try_recv() { while let Ok(transport::mqtt::MqttIncoming::ConfigUpdate(data)) = incoming_rx.try_recv() {
@@ -65,10 +103,35 @@ fn main() {
sys.refresh_cpu_usage(); sys.refresh_cpu_usage();
sys.refresh_memory(); sys.refresh_memory();
// Métriques lentes quotidiennes
let cur_yday = metrics::network_info::current_yday();
if cur_yday != slow_last_yday {
slow_last_yday = cur_yday;
slow_daily_done = false;
}
let mut daily_net: Option<Vec<payload::NetworkInterface>> = None;
let mut daily_hw: Option<payload::HardwareInfo> = None;
if !slow_daily_done {
let (ch, cm) = metrics::network_info::current_hhmm();
if ch == slow_time.0 && cm == slow_time.1 {
slow_daily_done = true;
if cfg.metrics.network_info.udp || cfg.metrics.network_info.mqtt {
let ni = metrics::network_info::collect(&cfg.server.ip, cfg.server.iperf3_port);
if !ni.is_empty() { daily_net = Some(ni); }
}
if cfg.metrics.hardware_info.udp || cfg.metrics.hardware_info.mqtt {
daily_hw = metrics::hardware::collect();
}
}
}
let mut m = payload::AgentMetrics { let mut m = payload::AgentMetrics {
hostname: hostname.clone(), hostname: hostname.clone(),
ip: ip.clone(), ip: ip.clone(),
status: "online".to_string(), status: "online".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
network_info: daily_net.or_else(|| startup_net.take()),
hardware_info: daily_hw.or_else(|| startup_hw.take()),
..Default::default() ..Default::default()
}; };
@@ -102,9 +165,8 @@ fn main() {
} }
if first_slow || now.duration_since(last_slow).as_secs() >= 60 { if first_slow || now.duration_since(last_slow).as_secs() >= 60 {
disks.refresh();
if cfg.metrics.disk.udp || cfg.metrics.disk.mqtt { if cfg.metrics.disk.udp || cfg.metrics.disk.mqtt {
let (used, free, total) = metrics::disk::get(&disks); let (used, free, total) = metrics::disk::get();
m.hdd_used = Some(used); m.hdd_used = Some(used);
m.hdd_free = Some(free); m.hdd_free = Some(free);
m.hdd_total = Some(total); m.hdd_total = Some(total);
@@ -135,4 +197,24 @@ fn main() {
std::thread::sleep(Duration::from_secs(2)); std::thread::sleep(Duration::from_secs(2));
} }
// Déconnexion propre : notifier le serveur avant de quitter
let offline = serde_json::to_string(&payload::AgentMetrics {
hostname: hostname.clone(),
ip: ip.clone(),
status: "offline".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
..Default::default()
}).unwrap_or_default();
if let Some(ref udp) = udp_sender {
udp.send(&offline);
}
if let Some(ref client) = mqtt_client {
transport::mqtt::publish_status(
client, &cfg.protocols.mqtt.topic_base, &hostname, "offline",
);
std::thread::sleep(Duration::from_millis(200)); // laisser le temps au broker de recevoir
let _ = client.disconnect();
}
} }
+22 -20
View File
@@ -1,34 +1,36 @@
use sysinfo::Disks; use std::mem::MaybeUninit;
pub fn get(disks: &Disks) -> (u64, u64, u64) { /// Retourne (used, free, total) en octets pour le système de fichiers racine "/".
for disk in disks.list() { /// Utilise statvfs() directement pour correspondre exactement aux chiffres de `df` :
let mount = disk.mount_point().to_string_lossy(); /// - total = f_blocks × f_frsize
if mount == "/" { /// - used = (f_blocks f_bfree) × f_frsize (blocs effectivement écrits)
let total = disk.total_space(); /// - free = f_bavail × f_frsize (disponible pour utilisateurs non-root)
let free = disk.available_space(); pub fn get() -> (u64, u64, u64) {
let used = total.saturating_sub(free); let path = b"/\0";
return (used, free, total); let mut stat = MaybeUninit::<libc::statvfs>::uninit();
} let ret = unsafe { libc::statvfs(path.as_ptr() as *const libc::c_char, stat.as_mut_ptr()) };
if ret != 0 {
return (0, 0, 0);
} }
if let Some(disk) = disks.list().first() { let stat = unsafe { stat.assume_init() };
let total = disk.total_space(); let bsize = stat.f_frsize as u64;
let free = disk.available_space(); let total = stat.f_blocks.saturating_mul(bsize);
return (total.saturating_sub(free), free, total); let used = stat.f_blocks.saturating_sub(stat.f_bfree).saturating_mul(bsize);
} let free = stat.f_bavail.saturating_mul(bsize);
(0, 0, 0) (used, free, total)
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use sysinfo::Disks;
#[test] #[test]
fn test_disk_coherent() { fn test_disk_coherent() {
let disks = Disks::new_with_refreshed_list(); let (used, free, total) = get();
let (used, free, total) = get(&disks); eprintln!("résultat statvfs : used={used} free={free} total={total}");
if total > 0 { if total > 0 {
assert!(used + free <= total + 1024, "used + free > total"); assert!(used + free <= total + 1024 * 1024, "used + free > total");
assert!(total > 0, "total doit être > 0");
} }
} }
} }
+72
View File
@@ -0,0 +1,72 @@
fn run_dmidecode(type_num: u8) -> String {
std::process::Command::new("dmidecode")
.args(["-t", &type_num.to_string()])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default()
}
fn extract_field(text: &str, key: &str) -> Option<String> {
for line in text.lines() {
let t = line.trim();
if t.starts_with(key) {
let val = t[key.len()..].trim().trim_start_matches(':').trim();
if !val.is_empty() && val != "Not Specified" && val != "Unknown"
&& val != "To Be Filled By O.E.M." {
return Some(val.to_string());
}
}
}
None
}
pub fn is_available() -> bool {
std::process::Command::new("which")
.arg("dmidecode")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
pub fn collect() -> Option<crate::payload::HardwareInfo> {
if !is_available() { return None; }
let board = run_dmidecode(2); // Baseboard
let cpu = run_dmidecode(4); // Processor
let mem = run_dmidecode(17); // Memory Device
let mut slots_total: i64 = 0;
let mut slots_used: i64 = 0;
let mut ram_type: Option<String> = None;
let mut ram_speed: Option<i64> = None;
for block in mem.split("\n\n") {
if !block.contains("Memory Device") { continue; }
slots_total += 1;
if let Some(size) = extract_field(block, "Size") {
if !size.contains("No Module") && size != "0" {
slots_used += 1;
}
}
if ram_type.is_none() {
ram_type = extract_field(block, "Type")
.filter(|t| t != "Unknown" && t != "Other");
}
if ram_speed.is_none() {
if let Some(spd) = extract_field(block, "Speed") {
ram_speed = spd.split_whitespace().next()
.and_then(|s| s.parse().ok());
}
}
}
Some(crate::payload::HardwareInfo {
motherboard_vendor: extract_field(&board, "Manufacturer"),
motherboard_model: extract_field(&board, "Product Name"),
cpu_model: extract_field(&cpu, "Version").or_else(|| extract_field(&cpu, "Family")),
ram_type,
ram_speed_mhz: ram_speed,
ram_slots_used: if slots_total > 0 { Some(slots_used) } else { None },
ram_slots_total: if slots_total > 0 { Some(slots_total) } else { None },
})
}
+2
View File
@@ -1,7 +1,9 @@
pub mod cpu; pub mod cpu;
pub mod disk; pub mod disk;
pub mod hardware;
pub mod memory; pub mod memory;
pub mod network; pub mod network;
pub mod network_info;
pub mod smart; pub mod smart;
pub mod temperature; pub mod temperature;
pub mod uptime; pub mod uptime;
+103
View File
@@ -0,0 +1,103 @@
use std::mem::MaybeUninit;
fn local_hhmm() -> (u32, u32) {
let now = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let mut tm = MaybeUninit::<libc::tm>::uninit();
unsafe { libc::localtime_r(&now, tm.as_mut_ptr()) };
let tm = unsafe { tm.assume_init() };
(tm.tm_hour as u32, tm.tm_min as u32)
}
pub fn current_hhmm() -> (u32, u32) {
local_hhmm()
}
pub fn current_yday() -> u32 {
let now = std::time::SystemTime::now()
.duration_since(std::time::SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
let mut tm = MaybeUninit::<libc::tm>::uninit();
unsafe { libc::localtime_r(&now, tm.as_mut_ptr()) };
unsafe { tm.assume_init() }.tm_yday as u32
}
fn is_physical(name: &str) -> bool {
if name == "lo" { return false; }
for prefix in &["veth", "docker", "br-", "virbr", "vir", "tun", "tap", "dummy", "bond"] {
if name.starts_with(prefix) { return false; }
}
true
}
fn read_sysfs(iface: &str, file: &str) -> Option<String> {
std::fs::read_to_string(format!("/sys/class/net/{}/{}", iface, file))
.ok()
.map(|s| s.trim().to_string())
}
fn is_wifi(name: &str) -> bool {
std::path::Path::new(&format!("/sys/class/net/{}/wireless", name)).exists()
}
fn wol_status(name: &str) -> Option<bool> {
let out = std::process::Command::new("ethtool").arg(name).output().ok()?;
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines() {
let t = line.trim();
if t.starts_with("Wake-on:") {
let val = t.split(':').nth(1)?.trim().to_string();
return Some(val != "d" && !val.is_empty());
}
}
None
}
fn iperf_mbps(server_ip: &str, port: u16) -> Option<f64> {
std::process::Command::new("which").arg("iperf3")
.output().ok()
.filter(|o| o.status.success())?;
let port_str = port.to_string();
let out = std::process::Command::new("iperf3")
.args(["-c", server_ip, "-p", &port_str, "-J", "-t", "5", "-P", "1"])
.output().ok()?;
let json = String::from_utf8_lossy(&out.stdout);
let v: serde_json::Value = serde_json::from_str(&json).ok()?;
let bps = v["end"]["sum_received"]["bits_per_second"].as_f64()?;
Some((bps / 1_000_000.0 * 10.0).round() / 10.0)
}
pub fn collect(server_ip: &str, iperf3_port: u16) -> Vec<crate::payload::NetworkInterface> {
let entries = match std::fs::read_dir("/sys/class/net") {
Ok(e) => e,
Err(_) => return vec![],
};
let mut ifaces: Vec<String> = entries
.flatten()
.map(|e| e.file_name().into_string().unwrap_or_default())
.filter(|n| is_physical(n))
.collect();
ifaces.sort();
if ifaces.is_empty() { return vec![]; }
let iperf = iperf_mbps(server_ip, iperf3_port);
ifaces.iter().map(|name| {
let speed = read_sysfs(name, "speed")
.and_then(|s| s.parse::<i64>().ok())
.filter(|&v| v > 0);
let mac = read_sysfs(name, "address").unwrap_or_default();
let wifi = is_wifi(name);
crate::payload::NetworkInterface {
name: name.clone(),
if_type: if wifi { "wifi".to_string() } else { "ethernet".to_string() },
speed_mbps: speed,
mac,
wol: if wifi { None } else { wol_status(name) },
iperf_mbps: iperf,
}
}).collect()
}
+23 -10
View File
@@ -1,5 +1,4 @@
use serde::Deserialize; use serde::Deserialize;
use crate::payload::SmartMetrics;
#[derive(Deserialize)] #[derive(Deserialize)]
struct SmartJson { struct SmartJson {
@@ -42,7 +41,7 @@ pub fn is_available() -> bool {
.unwrap_or(false) .unwrap_or(false)
} }
pub fn parse_json(json: &str) -> Result<SmartMetrics, serde_json::Error> { pub fn parse_json(json: &str) -> Result<crate::payload::SmartMetrics, serde_json::Error> {
let s: SmartJson = serde_json::from_str(json)?; let s: SmartJson = serde_json::from_str(json)?;
let temperature = s.temperature.as_ref().map(|t| t.current) let temperature = s.temperature.as_ref().map(|t| t.current)
@@ -71,7 +70,8 @@ pub fn parse_json(json: &str) -> Result<SmartMetrics, serde_json::Error> {
} }
} }
Ok(SmartMetrics { Ok(crate::payload::SmartMetrics {
device: String::new(),
passed: s.smart_status.passed, passed: s.smart_status.passed,
temperature, temperature,
reallocated_sectors: reallocated, reallocated_sectors: reallocated,
@@ -80,19 +80,32 @@ pub fn parse_json(json: &str) -> Result<SmartMetrics, serde_json::Error> {
}) })
} }
pub fn collect() -> Option<SmartMetrics> { pub fn collect() -> Option<Vec<crate::payload::SmartMetrics>> {
if !is_available() { if !is_available() {
return None; return None;
} }
for dev in &["/dev/sda", "/dev/nvme0"] { let mut devs: Vec<String> = std::fs::read_dir("/sys/block")
let output = std::process::Command::new("smartctl") .into_iter()
.flatten()
.flatten()
.map(|e| e.file_name().into_string().unwrap_or_default())
.filter(|n| n.starts_with("sd") || n.starts_with("nvme"))
.map(|n| format!("/dev/{}", n))
.collect();
devs.sort();
let mut results = Vec::new();
for dev in &devs {
let Ok(output) = std::process::Command::new("smartctl")
.args(["-j", dev]) .args(["-j", dev])
.output() .output() else { continue };
.ok()?;
let json = String::from_utf8_lossy(&output.stdout); let json = String::from_utf8_lossy(&output.stdout);
if let Ok(metrics) = parse_json(&json) { if let Ok(metrics) = parse_json(&json) {
return Some(metrics); results.push(crate::payload::SmartMetrics {
device: dev.trim_start_matches("/dev/").to_string(),
..metrics
});
} }
} }
None if results.is_empty() { None } else { Some(results) }
} }
+28 -1
View File
@@ -5,6 +5,8 @@ pub struct AgentMetrics {
pub hostname: String, pub hostname: String,
pub ip: String, pub ip: String,
pub status: String, pub status: String,
#[serde(default)]
pub version: String,
pub cpu_percent: Option<f32>, pub cpu_percent: Option<f32>,
pub memory_used: Option<u64>, pub memory_used: Option<u64>,
pub memory_free: Option<u64>, pub memory_free: Option<u64>,
@@ -16,11 +18,36 @@ pub struct AgentMetrics {
pub network_rx: Option<u64>, pub network_rx: Option<u64>,
pub network_tx: Option<u64>, pub network_tx: Option<u64>,
pub temperature: Option<f32>, pub temperature: Option<f32>,
pub smart: Option<SmartMetrics>, pub smart: Option<Vec<SmartMetrics>>,
pub network_info: Option<Vec<NetworkInterface>>,
pub hardware_info: Option<HardwareInfo>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct NetworkInterface {
pub name: String,
pub if_type: String,
pub speed_mbps: Option<i64>,
pub mac: String,
pub wol: Option<bool>,
pub iperf_mbps: Option<f64>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct HardwareInfo {
pub motherboard_vendor: Option<String>,
pub motherboard_model: Option<String>,
pub cpu_model: Option<String>,
pub ram_type: Option<String>,
pub ram_speed_mhz: Option<i64>,
pub ram_slots_used: Option<i64>,
pub ram_slots_total: Option<i64>,
} }
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
pub struct SmartMetrics { pub struct SmartMetrics {
#[serde(default)]
pub device: String,
pub passed: bool, pub passed: bool,
pub temperature: Option<i64>, pub temperature: Option<i64>,
pub reallocated_sectors: Option<i64>, pub reallocated_sectors: Option<i64>,
+5
View File
@@ -78,3 +78,8 @@ pub fn publish_metrics(client: &Client, topic_base: &str, hostname: &str, json:
let topic = format!("{}/{}/metrics", topic_base, hostname); let topic = format!("{}/{}/metrics", topic_base, hostname);
let _ = client.publish(topic, QoS::AtMostOnce, false, json); let _ = client.publish(topic, QoS::AtMostOnce, false, json);
} }
pub fn publish_status(client: &Client, topic_base: &str, hostname: &str, status: &str) {
let topic = format!("{}/{}/status", topic_base, hostname);
let _ = client.publish(topic, QoS::AtLeastOnce, true, status);
}
+4 -2
View File
@@ -18,6 +18,7 @@ fn test_serialize_json_complet() {
temperature: None, temperature: None,
smart: None, smart: None,
status: "online".to_string(), status: "online".to_string(),
version: "0.0.0".to_string(),
}; };
let json = serde_json::to_string(&m).unwrap(); let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("\"hostname\":\"srv-01\"")); assert!(json.contains("\"hostname\":\"srv-01\""));
@@ -31,13 +32,14 @@ fn test_serialize_avec_smart() {
let m = AgentMetrics { let m = AgentMetrics {
hostname: "srv-01".to_string(), hostname: "srv-01".to_string(),
ip: "10.0.0.11".to_string(), ip: "10.0.0.11".to_string(),
smart: Some(SmartMetrics { smart: Some(vec![SmartMetrics {
device: "sda".to_string(),
passed: true, passed: true,
temperature: Some(34), temperature: Some(34),
reallocated_sectors: Some(0), reallocated_sectors: Some(0),
power_on_hours: Some(4213), power_on_hours: Some(4213),
wear_level: Some(98), wear_level: Some(98),
}), }]),
status: "online".to_string(), status: "online".to_string(),
..Default::default() ..Default::default()
}; };
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+36 -8
View File
@@ -115,9 +115,26 @@ body{background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-s
.g-bar{flex:1;height:5px;border-radius:3px;background:var(--bg-1);overflow:hidden} .g-bar{flex:1;height:5px;border-radius:3px;background:var(--bg-1);overflow:hidden}
.g-fill{height:100%;border-radius:3px;background:var(--ok);transition:width .3s} .g-fill{height:100%;border-radius:3px;background:var(--ok);transition:width .3s}
.g-fill.w{background:var(--warn)}.g-fill.e{background:var(--err)} .g-fill.w{background:var(--warn)}.g-fill.e{background:var(--err)}
.g-val{font-family:var(--font-mono);font-size:11px;color:var(--ink-2);width:34px;text-align:right} .g-val{font-family:var(--font-mono);font-size:11px;color:var(--ink-2);
min-width:34px;white-space:nowrap;flex-shrink:0;text-align:right}
/* Jauge standard (BatteryGauge) */
.gs-row{display:flex;flex-direction:column;gap:3px}
.gs-header{display:flex;align-items:center;gap:6px}
.gs-ico{width:14px;text-align:center;font-size:10px;color:var(--ink-3);flex-shrink:0;cursor:help}
.gs-lbl{flex:1;font-family:var(--font-terminal);font-size:10px;color:var(--ink-3);letter-spacing:.04em}
.gs-val{font-family:var(--font-mono);font-size:11px;color:var(--ink-2);white-space:nowrap;flex-shrink:0}
.gs-bar{position:relative;height:9px;border-radius:3px;background:var(--bg-1);
border:1px solid var(--border-1);overflow:hidden;box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}
.gs-fill{height:100%;border-radius:2px;background:var(--ok);transition:width .3s}
.gs-fill.w{background:var(--warn)}.gs-fill.e{background:var(--err)}
.gs-gloss{position:absolute;inset:0;background:linear-gradient(180deg,rgba(255,255,255,.12),transparent);pointer-events:none}
.tile-foot{font-family:var(--font-terminal);font-size:10px;color:var(--ink-4); .tile-foot{font-family:var(--font-terminal);font-size:10px;color:var(--ink-4);
display:flex;align-items:center;gap:5px;user-select:none} display:flex;align-items:center;justify-content:space-between;user-select:none}
.tile-foot-info{display:flex;align-items:center;gap:5px;min-width:0;overflow:hidden}
.btn-del-agent{border:none;background:transparent;cursor:pointer;flex-shrink:0;
color:var(--ink-5);font-size:11px;padding:2px 4px;border-radius:4px;
line-height:1;transition:color .15s,background .15s;user-select:none}
.btn-del-agent:hover{color:var(--err);background:color-mix(in srgb,var(--err) 12%,transparent)}
/* FOOTER */ /* FOOTER */
.footer{background:var(--bg-0);border-top:1px solid var(--border-2);height:26px; .footer{background:var(--bg-0);border-top:1px solid var(--border-2);height:26px;
@@ -181,12 +198,16 @@ body{background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-s
.chart-cur{font-family:var(--font-mono);font-size:16px;font-weight:700} .chart-cur{font-family:var(--font-mono);font-size:16px;font-weight:700}
.chart-svg{width:100%;height:52px;display:block} .chart-svg{width:100%;height:52px;display:block}
.chart-axis{display:flex;justify-content:space-between;margin-top:2px;font-family:var(--font-terminal);font-size:9px;color:var(--ink-4)} .chart-axis{display:flex;justify-content:space-between;margin-top:2px;font-family:var(--font-terminal);font-size:9px;color:var(--ink-4)}
.smart-btn{display:inline-flex;align-items:center;gap:8px;padding:7px 12px;border-radius:8px; .chart-minmax{display:flex;justify-content:space-between;margin-top:3px;font-family:var(--font-mono);font-size:9px;color:var(--ink-4)}
border:1px solid var(--border-2);background:var(--bg-3);cursor:pointer; .smart-pill{display:inline-flex;align-items:center;gap:3px;padding:1px 7px;border-radius:999px;
transition:background .12s,border-color .12s,transform .08s;font-family:var(--font-terminal);font-size:11px} font-size:9px;font-family:var(--font-terminal);font-weight:700;border:1px solid;
.smart-btn:hover{background:var(--bg-4)}.smart-btn:active{transform:translateY(1px)} cursor:pointer;user-select:none;flex-shrink:0;
.smart-btn.ok{border-color:rgba(77,187,38,.3);color:var(--ok)} transition:opacity .12s,transform .08s,box-shadow .12s}
.smart-dot{width:7px;height:7px;border-radius:50%;background:var(--ok);box-shadow:0 0 5px var(--ok)} .smart-pill:hover{opacity:.82;transform:scale(1.06)}
.smart-pill.ok{color:var(--ok);background:rgba(77,187,38,.12);border-color:rgba(77,187,38,.32)}
.smart-pill.old{color:var(--warn);background:rgba(250,189,47,.12);border-color:rgba(250,189,47,.32)}
.smart-pill.prefail{color:var(--accent);background:var(--accent-tint);border-color:rgba(254,128,25,.32)}
.smart-pill.hs{color:var(--err);background:rgba(251,73,52,.12);border-color:rgba(251,73,52,.32)}
.meta-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px} .meta-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.meta{background:var(--bg-3);border-radius:6px;padding:8px 10px;border:1px solid var(--border-1)} .meta{background:var(--bg-3);border-radius:6px;padding:8px 10px;border:1px solid var(--border-1)}
.meta-lbl{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.06em} .meta-lbl{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.06em}
@@ -258,5 +279,12 @@ body{background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-s
border-radius:6px;background:var(--bg-3);border:1px solid var(--border-1)} border-radius:6px;background:var(--bg-3);border:1px solid var(--border-1)}
.attr-ok{color:var(--ok)} .attr-ok{color:var(--ok)}
/* Réseau + Hardware */
.net-table{display:flex;flex-direction:column;gap:3px}
.net-row{display:grid;grid-template-columns:18px 1fr 56px 130px 90px 90px;
align-items:center;gap:8px;padding:6px 10px;
background:var(--bg-3);border-radius:6px;border:1px solid var(--border-1);
font-family:var(--font-terminal);font-size:10px;color:var(--ink-2)}
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:var(--bg-1)} ::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:var(--bg-1)}
::-webkit-scrollbar-thumb{background:var(--bg-4);border-radius:3px} ::-webkit-scrollbar-thumb{background:var(--bg-4);border-radius:3px}
Binary file not shown.

After

Width:  |  Height:  |  Size: 173 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 B

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<!-- Fond arrondi -->
<rect width="64" height="64" rx="14" fill="#282828"/>
<!-- Cercle accent -->
<circle cx="32" cy="32" r="22" fill="none" stroke="#fe8019" stroke-width="4"/>
<!-- Barre CPU style jauge -->
<rect x="18" y="28" width="28" height="4" rx="2" fill="#504945"/>
<rect x="18" y="28" width="18" height="4" rx="2" fill="#fe8019"/>
<!-- Point LED vert -->
<circle cx="43" cy="21" r="4" fill="#b8bb26"/>
</svg>

After

Width:  |  Height:  |  Size: 498 B

+13
View File
@@ -0,0 +1,13 @@
{
"name": "Nanometrics",
"short_name": "Nanometrics",
"description": "Tableau de bord de surveillance système",
"start_url": "/",
"display": "standalone",
"background_color": "#282828",
"theme_color": "#fe8019",
"icons": [
{ "src": "favicon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "favicon-512.png", "sizes": "512x512", "type": "image/png" }
]
}
+37 -3
View File
@@ -4,6 +4,13 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nanometrics</title> <title>Nanometrics</title>
<link rel="icon" type="image/x-icon" href="favicon/favicon.ico">
<link rel="icon" type="image/svg+xml" href="favicon/favicon.svg">
<link rel="icon" type="image/png" sizes="32x32" href="favicon/favicon-32.png">
<link rel="icon" type="image/png" sizes="16x16" href="favicon/favicon-16.png">
<link rel="apple-touch-icon" sizes="180x180" href="favicon/favicon-180.png">
<link rel="manifest" href="favicon/site.webmanifest">
<meta name="theme-color" content="#282828">
<link rel="stylesheet" href="vendor/fontawesome/css/all.min.css"> <link rel="stylesheet" href="vendor/fontawesome/css/all.min.css">
<link rel="stylesheet" href="css/app.css"> <link rel="stylesheet" href="css/app.css">
</head> </head>
@@ -50,6 +57,10 @@
<span class="f-val" id="srv-mem"></span> <span class="f-val" id="srv-mem"></span>
<div class="f-minibar"><div class="f-minifill" id="srv-mem-bar"></div></div> <div class="f-minibar"><div class="f-minifill" id="srv-mem-bar"></div></div>
</div> </div>
<div class="f-cell" style="gap:4px">
<i class="fa-solid fa-code-branch" style="font-size:9px;color:var(--ink-4)"></i>
<span id="srv-ver" style="font-family:var(--font-mono);font-size:9px;color:var(--ink-4)"></span>
</div>
<div class="f-spacer"></div> <div class="f-spacer"></div>
<div class="f-right"> <div class="f-right">
<i class="fa-solid fa-rotate"></i> <i class="fa-solid fa-rotate"></i>
@@ -66,12 +77,12 @@
<img id="pop-icon-img" src="" alt="" style="display:none"> <img id="pop-icon-img" src="" alt="" style="display:none">
<div class="agent-icon-overlay"><i class="fa-solid fa-camera"></i><span>Changer</span></div> <div class="agent-icon-overlay"><i class="fa-solid fa-camera"></i><span>Changer</span></div>
</div> </div>
<input type="file" id="icon-upload" accept=".svg,.jpg,.jpeg,.png,.webp" style="display:none"> <input type="file" id="icon-upload" accept=".jpg,.jpeg,.png,.webp" style="display:none">
<div style="flex:1"> <div style="flex:1">
<div class="pop-host" id="pop-host"></div> <div class="pop-host" id="pop-host"></div>
<div class="pop-ip" id="pop-ip"></div> <div class="pop-ip" id="pop-ip"></div>
<div style="font-size:10px;color:var(--ink-4);font-family:var(--font-terminal);margin-top:2px"> <div id="icon-hint" style="font-size:10px;color:var(--ink-4);font-family:var(--font-terminal);margin-top:2px">
Cliquer sur l'icône pour personnaliser · SVG JPG PNG WEBP · max 128×128 px Cliquer sur l'icône pour personnaliser · JPG PNG WEBP · max 128×128 px
</div> </div>
</div> </div>
<div class="pop-led" id="pop-led"></div> <div class="pop-led" id="pop-led"></div>
@@ -147,6 +158,29 @@
</div> </div>
</div> </div>
<!-- DIALOG SUPPRESSION AGENT -->
<div class="overlay" id="overlay-del" style="display:none;z-index:400" onclick="if(event.target===this)this.style.display='none'">
<div class="popup" style="width:360px;max-width:96vw" onclick="event.stopPropagation()">
<div style="padding:20px 20px 0">
<div style="display:flex;align-items:center;gap:12px;margin-bottom:14px">
<div style="width:36px;height:36px;border-radius:8px;background:color-mix(in srgb,var(--err) 15%,transparent);display:flex;align-items:center;justify-content:center;color:var(--err);font-size:16px;flex-shrink:0"><i class="fa-solid fa-triangle-exclamation"></i></div>
<div>
<div style="font-weight:700;font-size:14px">Supprimer l'agent</div>
<div style="font-size:12px;color:var(--fg2);margin-top:2px">Cette action est irréversible</div>
</div>
</div>
<p style="font-size:13px;margin:0 0 8px">Supprimer <strong id="del-agent-name"></strong> ?<br>
<span style="font-size:11px;color:var(--fg2)">Toutes les métriques historiques seront effacées.</span></p>
</div>
<div style="padding:16px 20px;display:flex;justify-content:flex-end;gap:8px">
<button class="btn" onclick="document.getElementById('overlay-del').style.display='none'">Annuler</button>
<button class="btn" id="del-agent-confirm"
style="background:var(--err);color:#fff;border-color:var(--err)"
onclick="Popups.doDeleteAgent()"><i class="fa-solid fa-trash"></i> Supprimer</button>
</div>
</div>
</div>
<script src="js/api.js"></script> <script src="js/api.js"></script>
<script src="js/charts.js"></script> <script src="js/charts.js"></script>
<script src="js/grid.js"></script> <script src="js/grid.js"></script>
+6
View File
@@ -27,6 +27,11 @@ const API = (() => {
if (!r.ok) throw new Error(`PUT ${path}: ${r.status}`); if (!r.ok) throw new Error(`PUT ${path}: ${r.status}`);
} }
async function del(path) {
const r = await fetch(BASE + path, { method: 'DELETE' });
if (!r.ok) throw new Error(`DELETE ${path}: ${r.status}`);
}
async function postForm(path, formData) { async function postForm(path, formData) {
const r = await fetch(BASE + path, { method: 'POST', body: formData }); const r = await fetch(BASE + path, { method: 'POST', body: formData });
if (!r.ok) throw new Error(`POST ${path}: ${r.status}`); if (!r.ok) throw new Error(`POST ${path}: ${r.status}`);
@@ -44,6 +49,7 @@ const API = (() => {
fd.append('icon', file); fd.append('icon', file);
return postForm(`/api/agents/${id}/icon`, fd); return postForm(`/api/agents/${id}/icon`, fd);
}, },
deleteAgent: (id) => del(`/api/agents/${id}`),
iconUrl: (id) => `/api/agents/${id}/icon`, iconUrl: (id) => `/api/agents/${id}/icon`,
}; };
})(); })();
+5 -1
View File
@@ -50,6 +50,8 @@ const App = (() => {
const memEl = document.getElementById('srv-mem'); const memEl = document.getElementById('srv-mem');
const cpuBar = document.getElementById('srv-cpu-bar'); const cpuBar = document.getElementById('srv-cpu-bar');
const memBar = document.getElementById('srv-mem-bar'); const memBar = document.getElementById('srv-mem-bar');
const verEl = document.getElementById('srv-ver');
if (verEl && stats.version) verEl.textContent = 'v' + stats.version;
if (cpuEl) { if (cpuEl) {
cpuEl.textContent = cpu.toFixed(0) + '%'; cpuEl.textContent = cpu.toFixed(0) + '%';
cpuEl.className = 'f-val' + (cpu >= 70 ? ' w' : ''); cpuEl.className = 'f-val' + (cpu >= 70 ? ' w' : '');
@@ -87,6 +89,8 @@ const App = (() => {
updateServerStats(msg.data); updateServerStats(msg.data);
} else if (msg.type === 'status_update') { } else if (msg.type === 'status_update') {
Grid.updateStatus(msg.agent_id, msg.data.status); Grid.updateStatus(msg.agent_id, msg.data.status);
} else if (msg.type === 'agent_removed') {
Grid.removeAgent(msg.agent_id);
} }
} catch {} } catch {}
}; };
@@ -105,7 +109,7 @@ const App = (() => {
document.documentElement.style.setProperty('--tile-min', _serverConfig.tile_min_width + 'px'); document.documentElement.style.setProperty('--tile-min', _serverConfig.tile_min_width + 'px');
} }
if (_serverConfig.font_size) { if (_serverConfig.font_size) {
document.body.style.fontSize = _serverConfig.font_size + 'px'; document.documentElement.style.fontSize = _serverConfig.font_size + 'px';
} }
if (_serverConfig.popup_detail_w && _serverConfig.popup_detail_h) { if (_serverConfig.popup_detail_w && _serverConfig.popup_detail_h) {
const pd = document.getElementById('popup-detail'); const pd = document.getElementById('popup-detail');
+106 -25
View File
@@ -36,6 +36,40 @@ const Grid = (() => {
return ''; return '';
} }
function renderGaugeRow(faIcon, tip, label, pct, fillClass, valStr, extra) {
const standard = (App.serverConfig?.gauge_type ?? 'compact') === 'standard';
if (standard) {
return `<div class="gs-row">
<div class="gs-header">
<span class="gs-ico" data-tip="${tip}"><i class="fa-solid fa-${faIcon}"></i></span>
<span class="gs-lbl">${label}</span>
<span class="gs-val">${valStr}</span>${extra || ''}
</div>
<div class="gs-bar">
<div class="gs-fill ${fillClass}" style="width:${(pct ?? 0).toFixed(1)}%"></div>
<div class="gs-gloss"></div>
</div>
</div>`;
}
return `<div class="g-row">
<div class="g-ico" data-tip="${tip}"><i class="fa-solid fa-${faIcon}"></i></div>
<div class="g-bar"><div class="g-fill ${fillClass}" style="width:${(pct ?? 0).toFixed(0)}%"></div></div>
<span class="g-val">${valStr}</span>${extra || ''}
</div>`;
}
const _stateLabel = { ok: 'OK', old: 'USAGÉ', prefail: 'PREFAIL', hs: 'HS' };
function smartState(s) {
if (!s.passed) return 'hs';
if (s.reallocated_sectors > 0 ||
(s.wear_level != null && s.wear_level < 20) ||
(s.power_on_hours != null && s.power_on_hours > 40000)) return 'prefail';
if ((s.wear_level != null && s.wear_level < 50) ||
(s.power_on_hours != null && s.power_on_hours > 25000)) return 'old';
return 'ok';
}
function renderTile(agent, metrics) { function renderTile(agent, metrics) {
const id = agent.id; const id = agent.id;
const sc = statusClass(agent); const sc = statusClass(agent);
@@ -55,10 +89,21 @@ const Grid = (() => {
uptimeStr = d > 0 ? `${d}j ${h}h` : `${h}h`; uptimeStr = d > 0 ? `${d}j ${h}h` : `${h}h`;
} }
const smartIco = !offline && metrics?.smart?.length > 0
? '<div style="display:flex;gap:3px;flex-shrink:0">' +
metrics.smart.map((s, i) => {
const st = smartState(s);
const lbl = _stateLabel[st];
return `<span class="smart-pill ${st}"
onclick="event.stopPropagation();Popups.showSmart('${esc(id)}',${i})"
data-tip="SMART ${esc(s.device)}${lbl}">${lbl}</span>`;
}).join('') + '</div>'
: '';
const iconContent = `<img src="${API.iconUrl(id)}" alt="" const iconContent = `<img src="${API.iconUrl(id)}" alt=""
style="width:100%;height:100%;object-fit:cover;border-radius:7px" style="width:100%;height:100%;object-fit:cover;border-radius:7px"
onerror="this.style.display='none';this.nextSibling.style.display='flex'"> onerror="this.style.display='none';this.nextSibling.style.display='flex'">
<span style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:var(--accent)"> <span style="display:none;align-items:center;justify-content:center;width:100%;height:100%;color:var(--accent)">
<i class="fa-solid fa-server"></i></span>`; <i class="fa-solid fa-server"></i></span>`;
return `<div class="tile ${sc}" id="tile-${id}" onclick="Popups.showDetail('${esc(id)}')"> return `<div class="tile ${sc}" id="tile-${id}" onclick="Popups.showDetail('${esc(id)}')">
@@ -71,36 +116,59 @@ const Grid = (() => {
<div class="t-led ${ledClass(agent.status)}"></div> <div class="t-led ${ledClass(agent.status)}"></div>
</div> </div>
<div class="tile-gauges"> <div class="tile-gauges">
<div class="g-row"> ${renderGaugeRow('microchip', 'CPU', 'CPU',
<div class="g-ico" data-tip="CPU"><i class="fa-solid fa-microchip"></i></div> offline ? 0 : (cpu ?? 0),
<div class="g-bar"><div class="g-fill ${offline ? '' : gFill(cpu ?? 0)}" offline ? '' : gFill(cpu ?? 0),
style="width:${offline ? 0 : (cpu ?? 0).toFixed(0)}%"></div></div> offline ? '—' : fmtPct(cpu))}
<span class="g-val">${offline ? '—' : fmtPct(cpu)}</span> ${renderGaugeRow('memory', 'RAM', 'MÉMOIRE',
</div> offline ? 0 : (memPct ?? 0),
<div class="g-row"> offline ? '' : gFill(memPct ?? 0),
<div class="g-ico" data-tip="RAM"><i class="fa-solid fa-memory"></i></div> offline ? '—' : (metrics?.memory_used && metrics?.memory_total ? fmt(metrics.memory_used) + '/' + fmt(metrics.memory_total) : '—'))}
<div class="g-bar"><div class="g-fill ${offline ? '' : gFill(memPct ?? 0)}" ${renderGaugeRow('hard-drive', 'Disque', 'DISQUE',
style="width:${offline ? 0 : (memPct ?? 0).toFixed(0)}%"></div></div> offline ? 0 : (diskPct ?? 0),
<span class="g-val">${offline ? '' : fmtPct(memPct)}</span> offline ? '' : (diskPct >= (App.serverConfig?.warn_disk ?? 75) ? 'w' : ''),
</div> offline ? '—' : (metrics?.hdd_used && metrics?.hdd_total ? fmt(metrics.hdd_used) + '/' + fmt(metrics.hdd_total) : '—'),
<div class="g-row"> smartIco)}
<div class="g-ico" data-tip="Disque"><i class="fa-solid fa-hard-drive"></i></div>
<div class="g-bar"><div class="g-fill ${offline ? '' : (diskPct >= (App.serverConfig?.warn_disk ?? 75) ? 'w' : '')}"
style="width:${offline ? 0 : (diskPct ?? 0).toFixed(0)}%"></div></div>
<span class="g-val">${offline ? '—' : fmtPct(diskPct)}</span>
</div>
</div> </div>
<div class="tile-foot"> <div class="tile-foot">
${offline <span class="tile-foot-info">
${offline
? '<i class="fa-solid fa-circle-xmark" style="color:var(--err)"></i><span style="color:var(--err)">Hors ligne</span>' ? '<i class="fa-solid fa-circle-xmark" style="color:var(--err)"></i><span style="color:var(--err)">Hors ligne</span>'
: `<i class="fa-solid fa-clock"></i><span>${uptimeStr}</span>`} : `<i class="fa-solid fa-clock"></i><span>${uptimeStr || '—'}</span>`}
</span>
<button class="btn-del-agent" title="Supprimer cet agent"
onclick="event.stopPropagation();Popups.confirmDeleteAgent('${esc(id)}','${esc(agent.hostname)}')">
<i class="fa-solid fa-trash"></i>
</button>
</div> </div>
</div>`; </div>`;
} }
function update(agentId, metrics) { function update(agentId, metrics) {
const entry = _agents.get(agentId); let entry = _agents.get(agentId);
if (!entry) return; if (!entry) {
// Nouvel agent découvert via WebSocket — on crée la tuile à la volée
const agent = {
id: agentId,
hostname: metrics.hostname || agentId,
ip: metrics.ip || '',
status: metrics.status || 'online',
};
_agents.set(agentId, { agent, metrics });
const grid = document.getElementById('agents-grid');
if (grid) grid.insertAdjacentHTML('beforeend', renderTile(agent, metrics));
updateStats();
return;
}
// Mettre à jour ip/status depuis les métriques fraîches
if (metrics.ip) entry.agent.ip = metrics.ip;
if (metrics.status) entry.agent.status = metrics.status;
// Conserver les valeurs lentes (disque, smart) quand le paquet ne les contient pas
if (entry.metrics) {
for (const k of Object.keys(entry.metrics)) {
if (metrics[k] == null && entry.metrics[k] != null) metrics[k] = entry.metrics[k];
}
}
entry.metrics = metrics; entry.metrics = metrics;
const el = document.getElementById('tile-' + agentId); const el = document.getElementById('tile-' + agentId);
if (el) { if (el) {
@@ -112,7 +180,7 @@ const Grid = (() => {
function refresh(agents) { function refresh(agents) {
agents.forEach(a => { agents.forEach(a => {
if (!_agents.has(a.id)) { if (!_agents.has(a.id)) {
_agents.set(a.id, { agent: a, metrics: null }); _agents.set(a.id, { agent: a, metrics: a.last_metrics || null });
} else { } else {
_agents.get(a.id).agent = a; _agents.get(a.id).agent = a;
} }
@@ -139,6 +207,19 @@ const Grid = (() => {
document.getElementById('stat-err').textContent = err; document.getElementById('stat-err').textContent = err;
} }
function rerenderAll() {
const grid = document.getElementById('agents-grid');
if (!grid) return;
grid.innerHTML = [..._agents.values()].map(({ agent, metrics }) => renderTile(agent, metrics)).join('');
}
function removeAgent(id) {
_agents.delete(id);
const el = document.getElementById('tile-' + id);
if (el) el.remove();
updateStats();
}
function getAgent(id) { return _agents.get(id); } function getAgent(id) { return _agents.get(id); }
function updateStatus(agentId, status) { function updateStatus(agentId, status) {
@@ -150,5 +231,5 @@ const Grid = (() => {
updateStats(); updateStats();
} }
return { refresh, update, updateStatus, getAgent, fmt, fmtPct }; return { refresh, update, updateStatus, removeAgent, rerenderAll, getAgent, fmt, fmtPct, smartState };
})(); })();
+227 -40
View File
@@ -30,8 +30,27 @@ const Popups = (() => {
document.getElementById('icon-upload').onchange = async (e) => { document.getElementById('icon-upload').onchange = async (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
await API.uploadIcon(agentId, file); const hint = document.getElementById('icon-hint');
img.src = API.iconUrl(agentId) + '?t=' + Date.now(); try {
await API.uploadIcon(agentId, file);
const ts = '?t=' + Date.now();
img.src = API.iconUrl(agentId) + ts;
img.style.display = 'block';
document.getElementById('pop-icon-fa').style.display = 'none';
const tileImg = document.querySelector(`#tile-${CSS.escape(agentId)} .t-icon img`);
if (tileImg) tileImg.src = API.iconUrl(agentId) + ts;
} catch (err) {
if (hint) {
hint.style.color = 'var(--err)';
hint.textContent = 'Erreur : ' + (err.message || 'téléversement échoué');
setTimeout(() => {
hint.style.color = '';
hint.textContent = 'Cliquer sur l\'icône pour personnaliser · JPG PNG WEBP · max 128×128 px';
}, 4000);
}
} finally {
e.target.value = '';
}
}; };
// Uptime // Uptime
@@ -50,15 +69,29 @@ const Popups = (() => {
const cpuPts = Charts.historyToCpuPts(history); const cpuPts = Charts.historyToCpuPts(history);
const memPts = Charts.historyToMemPts(history); const memPts = Charts.historyToMemPts(history);
const smartBtn = metrics?.smart let ramMin = null, ramMax = null;
? `<div class="smart-btn ok" onclick="Popups.showSmart('${esc(agentId)}')" data-tip="Voir la santé complète du disque"> for (const h of history) {
<div class="smart-dot"></div> if (h.memory_used != null) {
<span style="font-weight:600">SMART</span> if (ramMin === null || h.memory_used < ramMin) ramMin = h.memory_used;
<span>·</span> if (ramMax === null || h.memory_used > ramMax) ramMax = h.memory_used;
<span>${metrics.smart.passed ? 'PASSED' : 'FAILED'}</span> }
${metrics.smart.temperature ? `<span style="font-family:var(--font-mono);font-size:10px;color:var(--ink-3)"><i class="fa-solid fa-temperature-half"></i> ${metrics.smart.temperature}°C</span>` : ''} }
<i class="fa-solid fa-chevron-right" style="font-size:10px;color:var(--ink-4);margin-left:auto"></i> const ramMinMax = ramMin !== null
</div>` ? `<div class="chart-minmax"><span>min ${Grid.fmt(ramMin)}</span><span>max ${Grid.fmt(ramMax)}</span></div>`
: '';
const smartBadges = metrics?.smart?.length > 0
? '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">' +
metrics.smart.map((s, i) => {
const st = Grid.smartState(s);
const lbl = { ok: 'OK', old: 'USAGÉ', prefail: 'PREFAIL', hs: 'HS' }[st];
return `<span class="smart-pill ${st}"
onclick="Popups.showSmart('${esc(agentId)}',${i})"
data-tip="Santé SMART de ${esc(s.device)}">
<i class="fa-solid fa-hard-drive" style="font-size:8px"></i>
${esc(s.device)} · ${lbl}
</span>`;
}).join('') + '</div>'
: ''; : '';
const protos = [ const protos = [
@@ -95,10 +128,14 @@ const Popups = (() => {
<div class="chart-card"> <div class="chart-card">
<div class="chart-header"> <div class="chart-header">
<div class="chart-label" style="color:var(--blue)"><i class="fa-solid fa-memory"></i>RAM</div> <div class="chart-label" style="color:var(--blue)"><i class="fa-solid fa-memory"></i>RAM</div>
<span class="chart-cur" style="color:var(--blue)">${Grid.fmtPct(metrics?.memory_used && metrics?.memory_total ? metrics.memory_used / metrics.memory_total * 100 : null)}</span> <div style="display:flex;align-items:baseline;gap:5px">
<span class="chart-cur" style="color:var(--blue)">${Grid.fmtPct(metrics?.memory_used && metrics?.memory_total ? metrics.memory_used / metrics.memory_total * 100 : null)}</span>
<span style="font-family:var(--font-mono);font-size:10px;color:var(--ink-3)">${Grid.fmt(metrics?.memory_used)}</span>
</div>
</div> </div>
<svg class="chart-svg" viewBox="0 0 200 52" preserveAspectRatio="none" id="det-mem-chart"></svg> <svg class="chart-svg" viewBox="0 0 200 52" preserveAspectRatio="none" id="det-mem-chart"></svg>
<div class="chart-axis"><span>30min</span><span>15min</span><span>now</span></div> <div class="chart-axis"><span>30min</span><span>15min</span><span>now</span></div>
${ramMinMax}
</div> </div>
</div> </div>
</div> </div>
@@ -111,14 +148,58 @@ const Popups = (() => {
<div style="height:100%;border-radius:4px;background:var(--ok);width:${metrics?.hdd_total ? (metrics.hdd_used/metrics.hdd_total*100).toFixed(0) : 0}%"></div></div> <div style="height:100%;border-radius:4px;background:var(--ok);width:${metrics?.hdd_total ? (metrics.hdd_used/metrics.hdd_total*100).toFixed(0) : 0}%"></div></div>
<span style="font-family:var(--font-mono);font-size:12px;color:var(--ink-2);width:90px;text-align:right">${Grid.fmt(metrics?.hdd_used)} / ${Grid.fmt(metrics?.hdd_total)}</span> <span style="font-family:var(--font-mono);font-size:12px;color:var(--ink-2);width:90px;text-align:right">${Grid.fmt(metrics?.hdd_used)} / ${Grid.fmt(metrics?.hdd_total)}</span>
</div> </div>
${smartBtn} ${smartBadges}
</div> </div>
</div> </div>
${(() => {
const ni = entry?.agent?.network_info;
if (!ni?.length) return '';
const wol = v => v == null ? '—' : v ? '<span style="color:var(--ok)">Oui</span>' : '<span style="color:var(--ink-4)">Non</span>';
const spd = v => v == null ? '—' : v >= 1000 ? '1 Gb' : v + ' Mb';
const rows = ni.map(iface => `
<div class="net-row">
<span style="color:var(--ink-3);font-size:12px"><i class="fa-solid fa-${iface.if_type === 'wifi' ? 'wifi' : 'ethernet'}"></i></span>
<span style="color:var(--ink-1);font-weight:600">${esc(iface.name)}</span>
<span style="color:var(--ink-3)">${spd(iface.speed_mbps)}</span>
<span style="color:var(--ink-4);font-size:9px;letter-spacing:.04em">${esc(iface.mac)}</span>
<span>WoL : ${wol(iface.wol)}</span>
<span style="color:var(--blue)">${iface.iperf_mbps != null ? iface.iperf_mbps.toFixed(1) + ' Mb/s' : '—'}</span>
</div>`).join('');
return `<div>
<div class="sec-title">RÉSEAU</div>
<div class="net-table">
<div class="net-row" style="background:var(--bg-4);font-size:9px;color:var(--ink-4);letter-spacing:.06em">
<span></span><span>INTERFACE</span><span>VITESSE</span><span>MAC</span><span>WAKE ON LAN</span><span>IPERF3</span>
</div>
${rows}
</div>
</div>`;
})()}
${(() => {
const hw = entry?.agent?.hardware_info;
if (!hw) return '';
const row = (lbl, val) => val ? `<div class="meta"><div class="meta-lbl">${lbl}</div><div class="meta-val">${esc(String(val))}</div></div>` : '';
const ramSlots = hw.ram_slots_used != null && hw.ram_slots_total != null
? `${hw.ram_slots_used}/${hw.ram_slots_total} slots` : null;
const ramInfo = [hw.ram_type, hw.ram_speed_mhz ? hw.ram_speed_mhz + ' MHz' : null, ramSlots]
.filter(Boolean).join(' · ') || null;
return `<div>
<div class="sec-title">HARDWARE</div>
<div class="meta-grid">
${row('CARTE MÈRE', hw.motherboard_vendor && hw.motherboard_model ? hw.motherboard_vendor + ' ' + hw.motherboard_model : hw.motherboard_model || hw.motherboard_vendor)}
${row('PROCESSEUR', hw.cpu_model)}
${row('MÉMOIRE RAM', ramInfo)}
</div>
</div>`;
})()}
<div> <div>
<div class="sec-title">INFORMATIONS</div> <div class="sec-title">INFORMATIONS</div>
<div class="meta-grid"> <div class="meta-grid">
<div class="meta"><div class="meta-lbl">HOSTNAME</div><div class="meta-val">${esc(agent.hostname)}</div></div> <div class="meta"><div class="meta-lbl">HOSTNAME</div><div class="meta-val">${esc(agent.hostname)}</div></div>
<div class="meta"><div class="meta-lbl">ADRESSE IP</div><div class="meta-val">${esc(agent.ip) || '—'}</div></div> <div class="meta"><div class="meta-lbl">ADRESSE IP</div><div class="meta-val">${esc(agent.ip) || '—'}</div></div>
<div class="meta"><div class="meta-lbl">VERSION AGENT</div><div class="meta-val" style="display:flex;align-items:center;gap:6px">
${agent.version ? `<span style="font-family:var(--font-mono);background:var(--bg-1);border:1px solid var(--border-2);border-radius:5px;padding:1px 7px;font-size:11px;color:var(--accent)">v${esc(agent.version)}</span>` : '<span style="color:var(--ink-4)">—</span>'}
</div></div>
<div class="meta"><div class="meta-lbl">PROTOCOLES ACTIFS</div><div style="display:flex;gap:5px;margin-top:4px">${protos || '—'}</div></div> <div class="meta"><div class="meta-lbl">PROTOCOLES ACTIFS</div><div style="display:flex;gap:5px;margin-top:4px">${protos || '—'}</div></div>
<div class="meta"><div class="meta-lbl">DERNIER CONTACT</div><div class="meta-val">${new Date(agent.last_seen * 1000).toLocaleTimeString('fr-FR')}</div></div> <div class="meta"><div class="meta-lbl">DERNIER CONTACT</div><div class="meta-val">${new Date(agent.last_seen * 1000).toLocaleTimeString('fr-FR')}</div></div>
</div> </div>
@@ -278,6 +359,11 @@ const Popups = (() => {
<input type="range" class="scfg-slider" min="10" max="18" value="${cfg.font_size ?? 13}" <input type="range" class="scfg-slider" min="10" max="18" value="${cfg.font_size ?? 13}"
oninput="this.nextElementSibling.textContent=this.value+'px'" id="s-font"> oninput="this.nextElementSibling.textContent=this.value+'px'" id="s-font">
<span class="scfg-val">${cfg.font_size ?? 13}px</span></div> <span class="scfg-val">${cfg.font_size ?? 13}px</span></div>
<div class="scfg-row"><label>Type de jauge</label>
<select class="scfg-select" id="s-gauge-type">
<option value="compact" ${(cfg.gauge_type ?? 'compact') === 'compact' ? 'selected' : ''}>Compact</option>
<option value="standard" ${(cfg.gauge_type ?? 'compact') === 'standard' ? 'selected' : ''}>Standard</option>
</select></div>
</div> </div>
<div style="display:flex;flex-direction:column;gap:8px"> <div style="display:flex;flex-direction:column;gap:8px">
<div class="scfg-sec-title">SEUILS D'ALERTE</div> <div class="scfg-sec-title">SEUILS D'ALERTE</div>
@@ -305,6 +391,20 @@ const Popups = (() => {
${[[15,'15 min'],[30,'30 min'],[60,'1 heure'],[360,'6 heures']].map(([v,l]) => ${[[15,'15 min'],[30,'30 min'],[60,'1 heure'],[360,'6 heures']].map(([v,l]) =>
`<option value="${v}" ${(cfg.chart_duration_min??30)==v?'selected':''}>${l}</option>`).join('')} `<option value="${v}" ${(cfg.chart_duration_min??30)==v?'selected':''}>${l}</option>`).join('')}
</select></div> </select></div>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div class="scfg-sec-title">INSTALLATION AGENT</div>
<div class="scfg-row" style="flex-direction:column;align-items:stretch;gap:6px">
<div style="display:flex;gap:6px;align-items:center">
<input type="text" id="s-install-cmd" readonly
style="flex:1;font-family:var(--font-mono);font-size:0.78em;padding:6px 8px;
background:var(--bg2);border:1px solid var(--border);border-radius:6px;
color:var(--fg);cursor:text;min-width:0"
value="SERVER_IP=${window.location.hostname} curl -fsSL https://git.maison43gil.com/gilles/nano_metrics/raw/branch/main/deploy/install.sh | sudo bash">
<button class="btn" style="padding:5px 10px;font-size:0.8em;white-space:nowrap"
onclick="Popups._copyInstallCmd(this)">Copier</button>
</div>
</div>
</div>`; </div>`;
document.getElementById('overlay-srvcfg').style.display = 'flex'; document.getElementById('overlay-srvcfg').style.display = 'flex';
} }
@@ -324,66 +424,110 @@ const Popups = (() => {
warn_disk: parseInt(document.getElementById('s-warn-disk')?.value ?? 75), warn_disk: parseInt(document.getElementById('s-warn-disk')?.value ?? 75),
retention_days: parseInt(document.getElementById('s-retention')?.value ?? 30), retention_days: parseInt(document.getElementById('s-retention')?.value ?? 30),
chart_duration_min: parseInt(document.getElementById('s-chart-dur')?.value ?? 30), chart_duration_min: parseInt(document.getElementById('s-chart-dur')?.value ?? 30),
gauge_type: document.getElementById('s-gauge-type')?.value ?? 'compact',
}; };
const prevGaugeType = App.serverConfig?.gauge_type ?? 'compact';
await API.putServerConfig(cfg); await API.putServerConfig(cfg);
App.serverConfig = cfg; App.serverConfig = cfg;
document.documentElement.style.setProperty('--tile-min', cfg.tile_min_width + 'px'); document.documentElement.style.setProperty('--tile-min', cfg.tile_min_width + 'px');
document.body.style.fontSize = cfg.font_size + 'px'; document.body.style.fontSize = cfg.font_size + 'px';
if (cfg.gauge_type !== prevGaugeType) Grid.rerenderAll();
hideSrvCfg(); hideSrvCfg();
} }
// ══ POPUP SMART ══ // ══ POPUP SMART ══
function showSmart(agentId) { function showSmart(agentId, diskIdx = 0) {
const m = Grid.getAgent(agentId)?.metrics?.smart; const smartList = Grid.getAgent(agentId)?.metrics?.smart;
if (!m) return; if (!smartList?.length) return;
document.getElementById('smart-sub').textContent = agentId; const m = smartList[diskIdx] ?? smartList[0];
const passColor = m.passed ? 'var(--ok)' : 'var(--err)'; const state = Grid.smartState(m);
const passText = m.passed ? 'Disque en bonne santé' : 'Disque en mauvais état';
const passSub = m.passed document.getElementById('smart-sub').textContent = m.device ? `${agentId}${m.device}` : agentId;
? 'Aucun problème détecté. Le disque fonctionne normalement.'
: 'Des problèmes ont été détectés. Envisagez un remplacement.'; const stateInfo = {
ok: { color:'var(--ok)', bg:'rgba(77,187,38,.1)', border:'rgba(77,187,38,.3)', icon:'fa-circle-check',
title:'Disque en bonne santé',
desc:'Aucun problème détecté. Votre disque fonctionne normalement.' },
old: { color:'var(--warn)', bg:'rgba(250,189,47,.1)', border:'rgba(250,189,47,.3)', icon:'fa-clock-rotate-left',
title:'Disque ancien ou très utilisé',
desc:'Votre disque fonctionne encore, mais il a accumulé beaucoup d\'heures. Pensez à prévoir un remplacement.' },
prefail: { color:'var(--accent)', bg:'var(--accent-tint)', border:'rgba(254,128,25,.3)', icon:'fa-triangle-exclamation',
title:'Signes de défaillance imminente',
desc:'Ce disque présente des indicateurs préoccupants. Sauvegardez vos données dès maintenant et envisagez un remplacement rapide.' },
hs: { color:'var(--err)', bg:'rgba(251,73,52,.1)', border:'rgba(251,73,52,.3)', icon:'fa-circle-xmark',
title:'Disque défaillant',
desc:'Ce disque a échoué au test SMART. Il peut tomber en panne à tout moment. Sauvegardez immédiatement et remplacez-le.' },
};
const si = stateInfo[state];
const tempColor = m.temperature == null ? null
: m.temperature > 60 ? 'var(--err)' : m.temperature > 50 ? 'var(--warn)' : 'var(--ok)';
const tempLabel = m.temperature == null ? null
: m.temperature > 60 ? 'Critique' : m.temperature > 50 ? 'Élevée' : 'Normale';
const tempBg = tempColor === 'var(--ok)' ? 'rgba(77,187,38,.15)'
: tempColor === 'var(--warn)' ? 'rgba(250,189,47,.15)' : 'rgba(251,73,52,.15)';
const secColor = m.reallocated_sectors == null ? null
: m.reallocated_sectors === 0 ? 'var(--ok)' : m.reallocated_sectors < 10 ? 'var(--warn)' : 'var(--err)';
const secDesc = m.reallocated_sectors === 0
? 'Aucun secteur défectueux — parfait.'
: m.reallocated_sectors < 10 ? 'Quelques secteurs remplacés. Surveillez l\'évolution.'
: 'Nombreux secteurs défectueux — risque de panne élevé.';
const hoursColor = m.power_on_hours == null ? null
: m.power_on_hours > 40000 ? 'var(--err)' : m.power_on_hours > 25000 ? 'var(--warn)' : 'var(--ok)';
const wearColor = m.wear_level == null ? null
: m.wear_level < 20 ? 'var(--err)' : m.wear_level < 50 ? 'var(--warn)' : 'var(--ok)';
const wearDesc = m.wear_level == null ? ''
: m.wear_level >= 80 ? 'Très bonne durée de vie restante.'
: m.wear_level >= 50 ? 'Durée de vie acceptable, à surveiller.'
: m.wear_level >= 20 ? 'Durée de vie réduite — pensez au remplacement.'
: 'Durée de vie critique — remplacez ce SSD rapidement.';
document.getElementById('smart-body').innerHTML = ` document.getElementById('smart-body').innerHTML = `
<div class="smart-verdict" style="${m.passed ? '' : 'background:rgba(251,73,52,.1);border-color:rgba(251,73,52,.3)'}"> <div class="smart-verdict" style="background:${si.bg};border-color:${si.border}">
<div style="font-size:28px;color:${passColor}"><i class="fa-solid ${m.passed ? 'fa-circle-check' : 'fa-circle-xmark'}"></i></div> <div style="font-size:28px;color:${si.color}"><i class="fa-solid ${si.icon}"></i></div>
<div><div style="font-size:16px;font-weight:700;color:${passColor}">${passText}</div> <div>
<div style="font-size:12px;color:var(--ink-3);margin-top:3px">${passSub}</div></div> <div style="font-size:16px;font-weight:700;color:${si.color}">${si.title}</div>
<div style="font-size:12px;color:var(--ink-3);margin-top:3px">${si.desc}</div>
</div>
</div> </div>
<div> <div>
<div class="sec-title">POINTS DE CONTRÔLE</div> <div class="sec-title">POINTS DE CONTRÔLE</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
${m.temperature != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)"> ${m.temperature != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="color:var(--warn);font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-temperature-half"></i></span> <span style="color:${tempColor};font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-temperature-half"></i></span>
<span style="font-weight:600;font-size:12px;flex:1">Température</span> <span style="font-weight:600;font-size:12px;flex:1">Température</span>
<span style="font-size:10px;font-family:var(--font-terminal);font-weight:700;padding:1px 7px;border-radius:999px;background:rgba(77,187,38,.15);color:var(--ok)">Normale</span> <span style="font-size:10px;font-family:var(--font-terminal);font-weight:700;padding:1px 7px;border-radius:999px;background:${tempBg};color:${tempColor}">${tempLabel}</span>
</div> </div>
<div class="si-val">${m.temperature}<span class="u">°C</span></div> <div class="si-val">${m.temperature}<span class="u">°C</span></div>
<div class="si-desc">Idéal : 2050°C. Au-delà de 60°C le disque risque de s'abîmer.</div> <div class="si-desc">Normale entre 2050°C. Au-delà de 60°C le disque risque de s'abîmer.</div>
</div>` : ''} </div>` : ''}
${m.reallocated_sectors != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)"> ${m.reallocated_sectors != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="color:${m.reallocated_sectors > 0 ? 'var(--err)' : 'var(--ok)'};font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-circle-check"></i></span> <span style="color:${secColor};font-size:14px;width:22px;text-align:center"><i class="fa-solid ${m.reallocated_sectors === 0 ? 'fa-circle-check' : 'fa-circle-exclamation'}"></i></span>
<span style="font-weight:600;font-size:12px;flex:1">Secteurs défectueux</span> <span style="font-weight:600;font-size:12px;flex:1">Secteurs défectueux</span>
</div> </div>
<div class="si-val">${m.reallocated_sectors}<span class="u"> sect.</span></div> <div class="si-val">${m.reallocated_sectors}<span class="u"> sect.</span></div>
<div class="si-desc">S'ils apparaissent en grand nombre, une panne est imminente.</div> <div class="si-desc">${secDesc}</div>
</div>` : ''} </div>` : ''}
${m.power_on_hours != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)"> ${m.power_on_hours != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="color:var(--blue);font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-clock-rotate-left"></i></span> <span style="color:${hoursColor};font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-clock-rotate-left"></i></span>
<span style="font-weight:600;font-size:12px;flex:1">Heures de fonctionnement</span> <span style="font-weight:600;font-size:12px;flex:1">Durée de fonctionnement</span>
</div> </div>
<div class="si-val">${m.power_on_hours.toLocaleString('fr-FR')}<span class="u">h</span></div> <div class="si-val">${m.power_on_hours.toLocaleString('fr-FR')}<span class="u"> h</span></div>
<div class="si-desc">≈${Math.floor(m.power_on_hours/24)} jours. Un disque dure en moyenne 3 à 5 ans.</div> <div class="si-desc">≈${Math.floor(m.power_on_hours / 24)} jours d'utilisation. Un disque dur dure en moyenne 3 à 5 ans (25 00040 000 h).</div>
</div>` : ''} </div>` : ''}
${m.wear_level != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)"> ${m.wear_level != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px"> <div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<span style="color:var(--ok);font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-battery-full"></i></span> <span style="color:${wearColor};font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-battery-full"></i></span>
<span style="font-weight:600;font-size:12px;flex:1">Durée de vie SSD</span> <span style="font-weight:600;font-size:12px;flex:1">Durée de vie SSD restante</span>
</div> </div>
<div class="si-val">${m.wear_level}<span class="u">%</span></div> <div class="si-val">${m.wear_level}<span class="u">%</span></div>
<div class="si-desc">100% = neuf · 0% = fin de vie recommandée.</div> <div class="si-desc">${wearDesc}</div>
</div>` : ''} </div>` : ''}
</div> </div>
</div>`; </div>`;
@@ -391,10 +535,53 @@ const Popups = (() => {
document.getElementById('overlay-smart').style.display = 'flex'; document.getElementById('overlay-smart').style.display = 'flex';
} }
// ══ SUPPRESSION AGENT ══
let _delAgentId = null;
function confirmDeleteAgent(id, hostname) {
_delAgentId = id;
document.getElementById('del-agent-name').textContent = hostname || id;
document.getElementById('overlay-del').style.display = 'flex';
}
async function doDeleteAgent() {
if (!_delAgentId) return;
const id = _delAgentId;
document.getElementById('overlay-del').style.display = 'none';
_delAgentId = null;
try {
await API.deleteAgent(id);
Grid.removeAgent(id);
} catch (e) {
alert('Erreur lors de la suppression : ' + e.message);
}
}
function _copyInstallCmd(btn) {
const text = document.getElementById('s-install-cmd').value;
const done = () => { btn.textContent = '✓ Copié'; setTimeout(() => btn.textContent = 'Copier', 1500); };
if (window.isSecureContext && navigator.clipboard?.writeText) {
navigator.clipboard.writeText(text).then(done).catch(() => _copyFallback(text, done));
} else {
_copyFallback(text, done);
}
}
function _copyFallback(text, cb) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
document.body.appendChild(ta);
ta.focus();
ta.select();
try { document.execCommand('copy'); cb(); } catch (_) {}
document.body.removeChild(ta);
}
return { return {
showDetail, hideDetail, showDetail, hideDetail,
showAgentCfg, sendAgentConfig, toggleCbox, showAgentCfg, sendAgentConfig, toggleCbox,
showSrvCfg, hideSrvCfg, saveSrvCfg, showSrvCfg, hideSrvCfg, saveSrvCfg, confirmDeleteAgent, doDeleteAgent,
showSmart, showSmart, _copyInstallCmd,
}; };
})(); })();
+46 -28
View File
@@ -29,7 +29,23 @@ echo " Nanometrics Agent — Installation"
echo "======================================" echo "======================================"
echo "" echo ""
# ── 1. Détection de l'architecture ──────────────────────────────────────────── # ── 1. Dépendances système ─────────────────────────────────────────────────────
PKGS_NEEDED=()
for pkg in curl python3 smartmontools ethtool iperf3 dmidecode; do
dpkg -l "$pkg" 2>/dev/null | grep -q '^ii' || PKGS_NEEDED+=("$pkg")
done
if [ ${#PKGS_NEEDED[@]} -gt 0 ]; then
echo "→ Installation des paquets manquants : ${PKGS_NEEDED[*]}"
apt-get update -qq
apt-get install -y -qq "${PKGS_NEEDED[@]}"
ok "Paquets installés : ${PKGS_NEEDED[*]}"
else
ok "Dépendances système déjà présentes"
fi
echo ""
# ── 3. Détection de l'architecture ────────────────────────────────────────────
ARCH="$(uname -m)" ARCH="$(uname -m)"
case "$ARCH" in case "$ARCH" in
x86_64) LABEL="linux-amd64" ;; x86_64) LABEL="linux-amd64" ;;
@@ -42,7 +58,7 @@ case "$ARCH" in
esac esac
ok "Architecture détectée : $ARCH$LABEL" ok "Architecture détectée : $ARCH$LABEL"
# ── 2. Récupérer l'URL du binaire depuis la dernière release ────────────────── # ── 4. Récupérer l'URL du binaire depuis la dernière release ──────────────────
echo "→ Récupération de la dernière release..." echo "→ Récupération de la dernière release..."
ASSETS_JSON=$(curl -sf "$REPO_API/releases?limit=1&page=1") ASSETS_JSON=$(curl -sf "$REPO_API/releases?limit=1&page=1")
@@ -69,7 +85,7 @@ print(releases[0]['tag_name'])
ok "Release : $TAG — URL : $ASSET_URL" ok "Release : $TAG — URL : $ASSET_URL"
# ── 3. Télécharger le binaire ───────────────────────────────────────────────── # ── 5. Télécharger le binaire ─────────────────────────────────────────────────
TMP_BIN="$(mktemp)" TMP_BIN="$(mktemp)"
trap 'rm -f "$TMP_BIN"' EXIT trap 'rm -f "$TMP_BIN"' EXIT
@@ -78,43 +94,40 @@ curl -fsSL -o "$TMP_BIN" "$ASSET_URL"
chmod 755 "$TMP_BIN" chmod 755 "$TMP_BIN"
ok "Binaire téléchargé ($(du -sh "$TMP_BIN" | cut -f1))" ok "Binaire téléchargé ($(du -sh "$TMP_BIN" | cut -f1))"
# ── 4. Paramètres de configuration ──────────────────────────────────────────── # ── 6. Paramètres de configuration ────────────────────────────────────────────
echo "" echo ""
echo "--- Configuration du serveur ---" echo "--- Configuration du serveur ---"
if [ -z "${SERVER_IP:-}" ]; then SERVER_IP="${SERVER_IP:-10.0.0.50}"
read -rp "Adresse IP du serveur Nanometrics : " SERVER_IP
fi
if [ -z "${SERVER_IP:-}" ]; then
err "SERVER_IP est requis."
exit 1
fi
SERVER_PORT="${SERVER_PORT:-9999}" SERVER_PORT="${SERVER_PORT:-9999}"
MQTT_HOST="${MQTT_HOST:-10.0.0.3}"
MQTT_ENABLED="${MQTT_ENABLED:-false}" MQTT_ENABLED="${MQTT_ENABLED:-false}"
ok "Serveur : $SERVER_IP:$SERVER_PORT" ok "Serveur : $SERVER_IP:$SERVER_PORT | MQTT broker : $MQTT_HOST"
# ── 5. Installer le binaire ──────────────────────────────────────────────────── # ── 7. Installer le binaire ────────────────────────────────────────────────────
echo "" echo ""
echo "[1/5] Installation du binaire dans /usr/local/bin/" echo "[1/5] Installation du binaire..."
# Arrêter le service si en cours (le binaire ne peut pas être écrasé à chaud)
if systemctl is-active --quiet nanometrics-agent 2>/dev/null; then
warn "Service en cours — arrêt temporaire..."
systemctl stop nanometrics-agent
fi
cp "$TMP_BIN" "$INSTALL_BIN" cp "$TMP_BIN" "$INSTALL_BIN"
chmod 755 "$INSTALL_BIN" chmod 755 "$INSTALL_BIN"
ok "Binaire installé" ok "Binaire installé"
# ── 6. Créer le répertoire de configuration ─────────────────────────────────── # ── 8. Créer le répertoire de configuration ───────────────────────────────────
echo "[2/5] Création de $CONFIG_DIR" echo "[2/5] Création de $CONFIG_DIR"
mkdir -p "$CONFIG_DIR" mkdir -p "$CONFIG_DIR"
chmod 755 "$CONFIG_DIR" chmod 755 "$CONFIG_DIR"
ok "Répertoire créé" ok "Répertoire créé"
# ── 7. Écrire config.toml ───────────────────────────────────────────────────── # ── 9. Écrire config.toml ─────────────────────────────────────────────────────
echo "[3/5] Écriture de $CONFIG_FILE" echo "[3/5] Écriture de $CONFIG_FILE"
# Ne pas écraser une config existante (upgrade)
if [ -f "$CONFIG_FILE" ]; then
warn "config.toml déjà présent — conservé tel quel"
else
cat > "$CONFIG_FILE" << TOML cat > "$CONFIG_FILE" << TOML
[server] [server]
ip = "$SERVER_IP" ip = "$SERVER_IP"
@@ -125,7 +138,7 @@ enabled = true
[protocols.mqtt] [protocols.mqtt]
enabled = $MQTT_ENABLED enabled = $MQTT_ENABLED
host = "10.0.0.3" host = "$MQTT_HOST"
port = 1883 port = 1883
topic_base = "nanometrics/agents" topic_base = "nanometrics/agents"
auto_discovery = true auto_discovery = true
@@ -160,11 +173,14 @@ mqtt = false
udp = true udp = true
mqtt = false mqtt = false
TOML TOML
chmod 640 "$CONFIG_FILE" chmod 644 "$CONFIG_FILE"
ok "config.toml créé" ok "config.toml écrit"
fi
# ── 8. Installer le fichier service ────────────────────────────────────────── # S'assurer que le répertoire est accessible
chmod 644 "$CONFIG_FILE" 2>/dev/null || true
chmod 755 "$CONFIG_DIR"
# ── 10. Installer le fichier service ─────────────────────────────────────────
echo "[4/5] Installation du service systemd" echo "[4/5] Installation du service systemd"
curl -fsSL -o "$SERVICE_FILE" "$SERVICE_URL" curl -fsSL -o "$SERVICE_FILE" "$SERVICE_URL"
chmod 644 "$SERVICE_FILE" chmod 644 "$SERVICE_FILE"
@@ -172,7 +188,7 @@ systemctl daemon-reload
systemctl enable nanometrics-agent systemctl enable nanometrics-agent
ok "Service installé et activé" ok "Service installé et activé"
# ── 9. Démarrer le service ─────────────────────────────────────────────────── # ── 11. Démarrer le service ───────────────────────────────────────────────────
echo "[5/5] Démarrage du service" echo "[5/5] Démarrage du service"
systemctl restart nanometrics-agent systemctl restart nanometrics-agent
sleep 2 sleep 2
@@ -182,6 +198,8 @@ echo "=== Statut ==="
systemctl status nanometrics-agent --no-pager || true systemctl status nanometrics-agent --no-pager || true
echo "" echo ""
ok "Installation terminée — agent $TAG opérationnel" echo "======================================"
echo -e " ${GREEN}${NC} Nanometrics Agent ${TAG} installé"
echo "======================================"
echo " Config : $CONFIG_FILE" echo " Config : $CONFIG_FILE"
echo " Logs : journalctl -u nanometrics-agent -f" echo " Logs : journalctl -u nanometrics-agent -f"
+2 -1
View File
@@ -10,8 +10,9 @@ Restart=on-failure
RestartSec=5 RestartSec=5
DynamicUser=yes DynamicUser=yes
SupplementaryGroups=disk
ConfigurationDirectory=nanometrics ConfigurationDirectory=nanometrics
ConfigurationDirectoryMode=0750 ConfigurationDirectoryMode=0755
ProtectSystem=strict ProtectSystem=strict
ProtectHome=read-only ProtectHome=read-only
+6 -2
View File
@@ -25,7 +25,9 @@ DESCRIPTION="${2:-Release $TAG}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CARGO_TOML="$ROOT/agent/Cargo.toml" CARGO_TOML="$ROOT/agent/Cargo.toml"
# ── 1. Compiler pour toutes les cibles supportées ────────────────────────── mkdir -p "$ROOT/dist"
# ── 1. Compiler l'agent pour toutes les cibles supportées ────────────────
echo "=== Compilation de l'agent ===" echo "=== Compilation de l'agent ==="
TARGETS=("x86_64-unknown-linux-musl" "aarch64-unknown-linux-musl") TARGETS=("x86_64-unknown-linux-musl" "aarch64-unknown-linux-musl")
LABELS=("linux-amd64" "linux-arm64") LABELS=("linux-amd64" "linux-arm64")
@@ -40,7 +42,9 @@ for i in "${!TARGETS[@]}"; do
# Installer la cible si absente # Installer la cible si absente
rustup target add "$TARGET" 2>/dev/null || true rustup target add "$TARGET" 2>/dev/null || true
# Compiler # Compiler (CC requis par la crate ring pour les cibles musl cross)
CC_aarch64_unknown_linux_musl=aarch64-linux-gnu-gcc \
AR_aarch64_unknown_linux_musl=aarch64-linux-gnu-ar \
cargo build --release \ cargo build --release \
--manifest-path "$CARGO_TOML" \ --manifest-path "$CARGO_TOML" \
--target "$TARGET" \ --target "$TARGET" \
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+6 -6
View File
@@ -1,14 +1,14 @@
FROM golang:1.22-alpine AS builder ARG GO_IMAGE=public.ecr.aws/docker/library/golang:1.25-alpine
FROM ${GO_IMAGE} AS builder
WORKDIR /app WORKDIR /app
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o nanometrics-server . RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o nanometrics-server .
FROM alpine:3.19 FROM scratch
RUN apk add --no-cache ca-certificates COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
WORKDIR /app COPY --from=builder /app/nanometrics-server /nanometrics-server
COPY --from=builder /app/nanometrics-server .
VOLUME /data VOLUME /data
EXPOSE 8080 9999/udp EXPOSE 8080 9999/udp
CMD ["./nanometrics-server"] CMD ["/nanometrics-server"]
+2 -1
View File
@@ -1,5 +1,6 @@
# Dockerfile de dev : utilise le binaire pré-compilé localement (pas de pull Docker Hub) # Dockerfile de dev : utilise le binaire pré-compilé localement (pas de pull Docker Hub)
FROM nginx:alpine ARG NGINX_IMAGE=public.ecr.aws/docker/library/nginx:alpine
FROM ${NGINX_IMAGE}
COPY nanometrics-server /app/nanometrics-server COPY nanometrics-server /app/nanometrics-server
WORKDIR /app WORKDIR /app
VOLUME /data VOLUME /data
+5
View File
@@ -0,0 +1,5 @@
ARG ALPINE_IMAGE=public.ecr.aws/docker/library/alpine:latest
FROM ${ALPINE_IMAGE}
RUN apk add --no-cache iperf3
EXPOSE 5201
ENTRYPOINT ["iperf3"]
+119 -22
View File
@@ -20,7 +20,7 @@ const schema = `
CREATE TABLE IF NOT EXISTS agents ( CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY, hostname TEXT NOT NULL, id TEXT PRIMARY KEY, hostname TEXT NOT NULL,
ip TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'offline', ip TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'offline',
last_seen INTEGER NOT NULL DEFAULT 0 last_seen INTEGER NOT NULL DEFAULT 0, version TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS metrics ( CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL, ts INTEGER NOT NULL, id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL, ts INTEGER NOT NULL,
@@ -57,54 +57,76 @@ func Open(path string) (*DB, error) {
} }
func (d *DB) migrate() error { func (d *DB) migrate() error {
_, err := d.conn.Exec(schema) if _, err := d.conn.Exec(schema); err != nil {
return err return err
}
// Migrations additives — ignorées si la colonne existe déjà
_, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN version TEXT NOT NULL DEFAULT ''`)
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_passed INTEGER`)
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_temp INTEGER`)
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_realloc INTEGER`)
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_hours INTEGER`)
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_wear INTEGER`)
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_json TEXT`)
_, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN network_info_json TEXT`)
_, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN hardware_info_json TEXT`)
return nil
} }
func (d *DB) Close() { _ = d.conn.Close() } func (d *DB) Close() { _ = d.conn.Close() }
func (d *DB) UpsertAgent(m *models.AgentMetrics) error { func (d *DB) UpsertAgent(m *models.AgentMetrics) error {
ts := time.Now().Unix() ts := time.Now().Unix()
var netJSON, hwJSON interface{}
if len(m.NetworkInfo) > 0 {
if b, err := json.Marshal(m.NetworkInfo); err == nil {
netJSON = string(b)
}
}
if m.HardwareInfo != nil {
if b, err := json.Marshal(m.HardwareInfo); err == nil {
hwJSON = string(b)
}
}
_, err := d.conn.Exec(` _, err := d.conn.Exec(`
INSERT INTO agents (id, hostname, ip, status, last_seen) INSERT INTO agents (id, hostname, ip, status, last_seen, version,
VALUES (?, ?, ?, ?, ?) network_info_json, hardware_info_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
ip=excluded.ip, status=excluded.status, last_seen=excluded.last_seen`, ip=excluded.ip, status=excluded.status, last_seen=excluded.last_seen,
m.Hostname, m.Hostname, m.IP, m.Status, ts) version=CASE WHEN excluded.version != '' THEN excluded.version ELSE version END,
network_info_json=CASE WHEN excluded.network_info_json IS NOT NULL THEN excluded.network_info_json ELSE network_info_json END,
hardware_info_json=CASE WHEN excluded.hardware_info_json IS NOT NULL THEN excluded.hardware_info_json ELSE hardware_info_json END`,
m.Hostname, m.Hostname, m.IP, m.Status, ts, m.Version, netJSON, hwJSON)
return err return err
} }
func (d *DB) InsertMetrics(m *models.AgentMetrics) error { func (d *DB) InsertMetrics(m *models.AgentMetrics) error {
ts := time.Now().Unix() ts := time.Now().Unix()
var smartPassed, smartTemp, smartRealloc, smartHours, smartWear interface{} var smartJSON interface{}
if m.Smart != nil { if len(m.Smart) > 0 {
b := 0 if b, err := json.Marshal(m.Smart); err == nil {
if m.Smart.Passed { smartJSON = string(b)
b = 1
} }
smartPassed = b
smartTemp = m.Smart.Temperature
smartRealloc = m.Smart.ReallocatedSectors
smartHours = m.Smart.PowerOnHours
smartWear = m.Smart.WearLevel
} }
_, err := d.conn.Exec(` _, err := d.conn.Exec(`
INSERT INTO metrics (agent_id, ts, INSERT INTO metrics (agent_id, ts,
cpu_percent, memory_used, memory_free, memory_total, cpu_percent, memory_used, memory_free, memory_total,
hdd_used, hdd_free, hdd_total, hdd_used, hdd_free, hdd_total,
uptime, network_rx, network_tx, temperature, uptime, network_rx, network_tx, temperature,
smart_passed, smart_temp, smart_realloc, smart_hours, smart_wear) smart_json)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
m.Hostname, ts, m.Hostname, ts,
m.CPUPercent, m.MemoryUsed, m.MemoryFree, m.MemoryTotal, m.CPUPercent, m.MemoryUsed, m.MemoryFree, m.MemoryTotal,
m.HDDUsed, m.HDDFree, m.HDDTotal, m.HDDUsed, m.HDDFree, m.HDDTotal,
m.Uptime, m.NetworkRX, m.NetworkTX, m.Temperature, m.Uptime, m.NetworkRX, m.NetworkTX, m.Temperature,
smartPassed, smartTemp, smartRealloc, smartHours, smartWear) smartJSON)
return err return err
} }
func (d *DB) GetAgents() ([]models.Agent, error) { func (d *DB) GetAgents() ([]models.Agent, error) {
rows, err := d.conn.Query(`SELECT id, hostname, ip, status, last_seen FROM agents`) rows, err := d.conn.Query(`SELECT id, hostname, ip, status, last_seen, version,
network_info_json, hardware_info_json FROM agents`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -112,9 +134,17 @@ func (d *DB) GetAgents() ([]models.Agent, error) {
var agents []models.Agent var agents []models.Agent
for rows.Next() { for rows.Next() {
var a models.Agent var a models.Agent
if err := rows.Scan(&a.ID, &a.Hostname, &a.IP, &a.Status, &a.LastSeen); err != nil { var netJSON, hwJSON *string
if err := rows.Scan(&a.ID, &a.Hostname, &a.IP, &a.Status, &a.LastSeen, &a.Version,
&netJSON, &hwJSON); err != nil {
return nil, err return nil, err
} }
if netJSON != nil {
_ = json.Unmarshal([]byte(*netJSON), &a.NetworkInfo)
}
if hwJSON != nil {
_ = json.Unmarshal([]byte(*hwJSON), &a.HardwareInfo)
}
agents = append(agents, a) agents = append(agents, a)
} }
if err := rows.Err(); err != nil { if err := rows.Err(); err != nil {
@@ -123,6 +153,59 @@ func (d *DB) GetAgents() ([]models.Agent, error) {
return agents, nil return agents, nil
} }
func (d *DB) GetLastMetrics(agentID string) (*models.AgentMetrics, error) {
var cpu, temperature *float64
var memUsed, memFree, memTotal, hddUsed, hddFree, hddTotal *int64
var uptime, netRX, netTX *int64
var smartJSON *string
err := d.conn.QueryRow(`
SELECT
(SELECT cpu_percent FROM metrics WHERE agent_id=? AND cpu_percent IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT memory_used FROM metrics WHERE agent_id=? AND memory_used IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT memory_free FROM metrics WHERE agent_id=? AND memory_free IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT memory_total FROM metrics WHERE agent_id=? AND memory_total IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT hdd_used FROM metrics WHERE agent_id=? AND hdd_used IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT hdd_free FROM metrics WHERE agent_id=? AND hdd_free IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT hdd_total FROM metrics WHERE agent_id=? AND hdd_total IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT uptime FROM metrics WHERE agent_id=? AND uptime IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT network_rx FROM metrics WHERE agent_id=? AND network_rx IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT network_tx FROM metrics WHERE agent_id=? AND network_tx IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT temperature FROM metrics WHERE agent_id=? AND temperature IS NOT NULL ORDER BY ts DESC LIMIT 1),
(SELECT smart_json FROM metrics WHERE agent_id=? AND smart_json IS NOT NULL ORDER BY ts DESC LIMIT 1)`,
agentID, agentID, agentID, agentID,
agentID, agentID, agentID,
agentID, agentID, agentID, agentID,
agentID).
Scan(&cpu, &memUsed, &memFree, &memTotal,
&hddUsed, &hddFree, &hddTotal,
&uptime, &netRX, &netTX, &temperature,
&smartJSON)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
m := &models.AgentMetrics{
CPUPercent: cpu,
MemoryUsed: memUsed,
MemoryFree: memFree,
MemoryTotal: memTotal,
HDDUsed: hddUsed,
HDDFree: hddFree,
HDDTotal: hddTotal,
Uptime: uptime,
NetworkRX: netRX,
NetworkTX: netTX,
Temperature: temperature,
}
if smartJSON != nil {
_ = json.Unmarshal([]byte(*smartJSON), &m.Smart)
}
return m, nil
}
func (d *DB) GetMetricsHistory(agentID string, from, to int64) ([]map[string]interface{}, error) { func (d *DB) GetMetricsHistory(agentID string, from, to int64) ([]map[string]interface{}, error) {
rows, err := d.conn.Query(` rows, err := d.conn.Query(`
SELECT ts, cpu_percent, memory_used, memory_total, hdd_used, hdd_total SELECT ts, cpu_percent, memory_used, memory_total, hdd_used, hdd_total
@@ -238,6 +321,20 @@ func (d *DB) MarkOffline(timeoutSec int64) error {
return err return err
} }
func (d *DB) DeleteAgent(agentID string) error {
for _, q := range []string{
`DELETE FROM metrics WHERE agent_id = ?`,
`DELETE FROM agent_configs WHERE agent_id = ?`,
`DELETE FROM agent_icons WHERE agent_id = ?`,
`DELETE FROM agents WHERE id = ?`,
} {
if _, err := d.conn.Exec(q, agentID); err != nil {
return err
}
}
return nil
}
// MarkOfflineAndGetIDs marque les agents inactifs et retourne leurs IDs. // MarkOfflineAndGetIDs marque les agents inactifs et retourne leurs IDs.
func (d *DB) MarkOfflineAndGetIDs(timeoutSec int64) ([]string, error) { func (d *DB) MarkOfflineAndGetIDs(timeoutSec int64) ([]string, error) {
cutoff := time.Now().Unix() - timeoutSec cutoff := time.Now().Unix() - timeoutSec
+1 -1
View File
@@ -15,7 +15,7 @@ services:
- "9999:9999/udp" - "9999:9999/udp"
dashboard: dashboard:
image: nginx:alpine image: ${NGINX_IMAGE:-public.ecr.aws/docker/library/nginx:alpine}
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
+19 -3
View File
@@ -1,7 +1,10 @@
version: '3.8'
services: services:
server: server:
build: . build:
context: .
pull: false
args:
GO_IMAGE: ${GO_IMAGE:-public.ecr.aws/docker/library/golang:1.25-alpine}
restart: unless-stopped restart: unless-stopped
environment: environment:
UDP_ADDR: "0.0.0.0:9999" UDP_ADDR: "0.0.0.0:9999"
@@ -15,7 +18,8 @@ services:
- "9999:9999/udp" - "9999:9999/udp"
dashboard: dashboard:
image: nginx:alpine image: ${NGINX_IMAGE:-public.ecr.aws/docker/library/nginx:alpine}
pull_policy: if_not_present
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
@@ -25,5 +29,17 @@ services:
depends_on: depends_on:
- server - server
iperf3:
build:
context: .
dockerfile: Dockerfile.iperf3
args:
ALPINE_IMAGE: ${ALPINE_IMAGE:-public.ecr.aws/docker/library/alpine:latest}
restart: unless-stopped
command: ["-s"]
ports:
- "5202:5201/tcp"
- "5202:5201/udp"
volumes: volumes:
nanometrics_data: nanometrics_data:
+47
View File
@@ -3,8 +3,10 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings"
"github.com/user/nanometrics/server/db" "github.com/user/nanometrics/server/db"
"github.com/user/nanometrics/server/models"
) )
func AgentsHandler(database *db.DB) http.HandlerFunc { func AgentsHandler(database *db.DB) http.HandlerFunc {
@@ -14,7 +16,52 @@ func AgentsHandler(database *db.DB) http.HandlerFunc {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return
} }
for i := range agents {
agents[i].LastMetrics, _ = database.GetLastMetrics(agents[i].ID)
}
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(agents) json.NewEncoder(w).Encode(agents)
} }
} }
func AgentDetailHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 3 {
http.Error(w, "invalid path", 400)
return
}
agentID := parts[2]
agents, err := database.GetAgents()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
for _, a := range agents {
if a.ID == agentID {
a.LastMetrics, _ = database.GetLastMetrics(agentID)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(a)
return
}
}
http.NotFound(w, r)
}
}
func DeleteAgentHandler(database *db.DB, broadcast func(interface{})) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 3 {
http.Error(w, "invalid path", 400)
return
}
agentID := parts[2]
if err := database.DeleteAgent(agentID); err != nil {
http.Error(w, err.Error(), 500)
return
}
broadcast(models.WSMessage{Type: "agent_removed", AgentID: agentID})
w.WriteHeader(http.StatusNoContent)
}
}
+1 -1
View File
@@ -26,7 +26,7 @@ func AgentConfigHandler(database *db.DB, pushConfig func(agentID string, cfg *mo
return return
} }
if cfg == nil { if cfg == nil {
cfg = &models.AgentConfig{} cfg = models.DefaultAgentConfig()
} }
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cfg) json.NewEncoder(w).Encode(cfg)
+2
View File
@@ -3,6 +3,7 @@ package handlers
import ( import (
"bytes" "bytes"
"image" "image"
_ "image/gif"
_ "image/jpeg" _ "image/jpeg"
"image/png" "image/png"
"io" "io"
@@ -11,6 +12,7 @@ import (
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
"github.com/user/nanometrics/server/db" "github.com/user/nanometrics/server/db"
_ "golang.org/x/image/webp"
) )
const maxIconSize = 128 const maxIconSize = 128
+7
View File
@@ -20,6 +20,8 @@ import (
ws "github.com/user/nanometrics/server/websocket" ws "github.com/user/nanometrics/server/websocket"
) )
const serverVersion = "0.1.0"
func main() { func main() {
cfg := config.Load() cfg := config.Load()
@@ -92,6 +94,7 @@ func main() {
if err != nil { if err != nil {
continue continue
} }
stats.Version = serverVersion
hub.Broadcast(models.WSMessage{Type: "server_stats", Data: stats}) hub.Broadcast(models.WSMessage{Type: "server_stats", Data: stats})
} }
}() }()
@@ -110,6 +113,10 @@ func main() {
handlers.IconUploadHandler(database)(w, r) handlers.IconUploadHandler(database)(w, r)
case endsWith(r.URL.Path, "/icon") && r.Method == http.MethodGet: case endsWith(r.URL.Path, "/icon") && r.Method == http.MethodGet:
handlers.IconGetHandler(database)(w, r) handlers.IconGetHandler(database)(w, r)
case r.Method == http.MethodDelete:
handlers.DeleteAgentHandler(database, hub.Broadcast)(w, r)
case r.Method == http.MethodGet:
handlers.AgentDetailHandler(database)(w, r)
default: default:
http.NotFound(w, r) http.NotFound(w, r)
} }
+75 -32
View File
@@ -1,24 +1,47 @@
package models package models
type AgentMetrics struct { type AgentMetrics struct {
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
IP string `json:"ip"` IP string `json:"ip"`
Status string `json:"status"` Status string `json:"status"`
CPUPercent *float64 `json:"cpu_percent"` Version string `json:"version"`
MemoryUsed *int64 `json:"memory_used"` CPUPercent *float64 `json:"cpu_percent"`
MemoryFree *int64 `json:"memory_free"` MemoryUsed *int64 `json:"memory_used"`
MemoryTotal *int64 `json:"memory_total"` MemoryFree *int64 `json:"memory_free"`
HDDUsed *int64 `json:"hdd_used"` MemoryTotal *int64 `json:"memory_total"`
HDDFree *int64 `json:"hdd_free"` HDDUsed *int64 `json:"hdd_used"`
HDDTotal *int64 `json:"hdd_total"` HDDFree *int64 `json:"hdd_free"`
Uptime *int64 `json:"uptime"` HDDTotal *int64 `json:"hdd_total"`
NetworkRX *int64 `json:"network_rx"` Uptime *int64 `json:"uptime"`
NetworkTX *int64 `json:"network_tx"` NetworkRX *int64 `json:"network_rx"`
Temperature *float64 `json:"temperature"` NetworkTX *int64 `json:"network_tx"`
Smart *SmartMetrics `json:"smart"` Temperature *float64 `json:"temperature"`
Smart []SmartMetrics `json:"smart"`
NetworkInfo []NetworkInterface `json:"network_info"`
HardwareInfo *HardwareInfo `json:"hardware_info"`
}
type NetworkInterface struct {
Name string `json:"name"`
IfType string `json:"if_type"`
SpeedMbps *int64 `json:"speed_mbps"`
MAC string `json:"mac"`
WoL *bool `json:"wol"`
IperfMbps *float64 `json:"iperf_mbps"`
}
type HardwareInfo struct {
MotherboardVendor *string `json:"motherboard_vendor"`
MotherboardModel *string `json:"motherboard_model"`
CPUModel *string `json:"cpu_model"`
RAMType *string `json:"ram_type"`
RAMSpeedMHz *int64 `json:"ram_speed_mhz"`
RAMSlotsUsed *int64 `json:"ram_slots_used"`
RAMSlotsTotal *int64 `json:"ram_slots_total"`
} }
type SmartMetrics struct { type SmartMetrics struct {
Device string `json:"device"`
Passed bool `json:"passed"` Passed bool `json:"passed"`
Temperature *int64 `json:"temperature"` Temperature *int64 `json:"temperature"`
ReallocatedSectors *int64 `json:"reallocated_sectors"` ReallocatedSectors *int64 `json:"reallocated_sectors"`
@@ -27,12 +50,15 @@ type SmartMetrics struct {
} }
type Agent struct { type Agent struct {
ID string `json:"id"` ID string `json:"id"`
Hostname string `json:"hostname"` Hostname string `json:"hostname"`
IP string `json:"ip"` IP string `json:"ip"`
Status string `json:"status"` Status string `json:"status"`
LastSeen int64 `json:"last_seen"` LastSeen int64 `json:"last_seen"`
LastMetrics *AgentMetrics `json:"last_metrics,omitempty"` Version string `json:"version,omitempty"`
LastMetrics *AgentMetrics `json:"last_metrics,omitempty"`
NetworkInfo []NetworkInterface `json:"network_info,omitempty"`
HardwareInfo *HardwareInfo `json:"hardware_info,omitempty"`
} }
type AgentConfig struct { type AgentConfig struct {
@@ -75,17 +101,32 @@ type MetricProto struct {
} }
type ServerConfig struct { type ServerConfig struct {
TileMinWidth int `json:"tile_min_width"` TileMinWidth int `json:"tile_min_width"`
FontSize int `json:"font_size"` FontSize int `json:"font_size"`
WarnCPU int `json:"warn_cpu"` WarnCPU int `json:"warn_cpu"`
ErrCPU int `json:"err_cpu"` ErrCPU int `json:"err_cpu"`
WarnDisk int `json:"warn_disk"` WarnDisk int `json:"warn_disk"`
RetentionDays int `json:"retention_days"` RetentionDays int `json:"retention_days"`
ChartDurationMin int `json:"chart_duration_min"` ChartDurationMin int `json:"chart_duration_min"`
HideOffline bool `json:"hide_offline"` HideOffline bool `json:"hide_offline"`
Notifications bool `json:"notifications"` Notifications bool `json:"notifications"`
PopupDetailW int `json:"popup_detail_w"` PopupDetailW int `json:"popup_detail_w"`
PopupDetailH int `json:"popup_detail_h"` PopupDetailH int `json:"popup_detail_h"`
GaugeType string `json:"gauge_type"`
}
func DefaultAgentConfig() *AgentConfig {
on := MetricProto{UDP: true, MQTT: false}
return &AgentConfig{
Protocols: ProtocolsConfig{UDP: UDPConfig{Enabled: true}},
Metrics: MetricsConfig{
CPU: on,
Memory: on,
Disk: on,
Smart: on,
Uptime: on,
},
}
} }
func DefaultServerConfig() ServerConfig { func DefaultServerConfig() ServerConfig {
@@ -95,6 +136,7 @@ func DefaultServerConfig() ServerConfig {
RetentionDays: 30, ChartDurationMin: 30, RetentionDays: 30, ChartDurationMin: 30,
HideOffline: false, Notifications: true, HideOffline: false, Notifications: true,
PopupDetailW: 560, PopupDetailH: 600, PopupDetailW: 560, PopupDetailH: 600,
GaugeType: "compact",
} }
} }
@@ -108,4 +150,5 @@ type ServerStats struct {
CPUPercent float64 `json:"cpu_percent"` CPUPercent float64 `json:"cpu_percent"`
MemUsed int64 `json:"mem_used"` MemUsed int64 `json:"mem_used"`
MemTotal int64 `json:"mem_total"` MemTotal int64 `json:"mem_total"`
Version string `json:"version"`
} }
BIN
View File
Binary file not shown.
+1
View File
@@ -2,6 +2,7 @@ server {
listen 80; listen 80;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
client_max_body_size 10m;
location /api/ { location /api/ {
proxy_pass http://server:8080; proxy_pass http://server:8080;