64 Commits
Author SHA1 Message Date
Gilles SoulierandClaude Sonnet 4.6 55e68189d3 fix(smart v0.1.10): extraction contrôleur NVMe — rfind au lieu de split
split('n').next() sur "nvme0n1" retourne "" (chaîne vide avant le premier 'n')
→ smartctl -a -j /dev/ (chemin invalide, échec silencieux, aucun SMART collecté)

Correction : rfind('n') trouve le dernier séparateur namespace (nvme0[n]1)
et n[..pos] donne le nom du contrôleur correct (nvme0, nvme10, etc.)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 07:34:45 +02:00
Gilles SoulierandClaude Sonnet 4.6 db6fc65ee1 fix(v0.1.9): détection IP/interface — filtre VPN WireGuard par flags kernel
- get_local_ip: construit d'abord la liste des IPs physiques (getifaddrs
  avec IFF_POINTOPOINT exclu + type=1 ARPHRD_ETHER requis), puis vérifie
  que l'IP choisie par le UDP-connect-trick en fait partie → évite de
  retourner une IP VPN même quand le trafic y est routé
- is_physical: remplace le filtrage par préfixe de nom par type kernel
  /sys/class/net/<iface>/type == 1 (Ethernet/WiFi) ; exclut WireGuard
  (type 65534), tunnels et autres interfaces virtuelles nommées librement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 07:28:14 +02:00
Gilles SoulierandClaude Sonnet 4.6 1002a6be68 fix: polices woff2 invalides + debounce ResizeObserver config
- jetbrains-mono.woff2 et share-tech-mono.woff2 étaient des fichiers HTML
  (pages 404 téléchargées par erreur) → remplacés par les vrais binaires wOF2
- JetBrains Mono : fichiers séparés regular/bold (400 et 700)
- ResizeObserver popup détail : debounce 600ms pour éviter 50+ PUT /api/config
  lors d'un resize

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 07:14:11 +02:00
Gilles SoulierandClaude Sonnet 4.6 017d7bb1bb fix(smart v0.1.8): NVMe — contrôleur correct + flag -a pour attributs complets
- /sys/block expose nvme0n1 (namespace), mais smartctl a besoin du contrôleur
  nvme0 → déduplication via HashSet pour éviter les doublons nvme0n1/nvme0
- smartctl -j → smartctl -a -j pour inclure nvme_smart_health_information_log
  (sans -a, le log de santé NVMe n'est pas dans la sortie JSON)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 07:07:45 +02:00
Gilles SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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 SoulierandClaude Sonnet 4.6 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
Gilles SoulierandClaude Sonnet 4.6 91f4b43b6f feat: scripts de déploiement et release multi-arch
- deploy/install.sh  : installeur curl-able (détecte l'arch, télécharge
  depuis la dernière release Gitea, configure le service systemd)
- deploy/release.sh  : build musl statique x86_64 + aarch64, crée la
  release Gitea et uploade les binaires en asset
- deploy/install-agent.sh : installeur local depuis le binaire compilé
- server/Dockerfile.dev + docker-compose.dev.yml : stack dev Docker

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 19:31:53 +02:00
Gilles Soulier 6eed88ac99 feat(server): DASHBOARD_DIR pour servir le dashboard en dev sans Nginx 2026-05-22 17:57:34 +02:00
Gilles Soulier 8a8198a51a feat: métriques serveur dans footer dashboard + notification offline WS 2026-05-22 17:46:55 +02:00
Gilles Soulier 6f79554cce docs: README installation agent, serveur et dashboard 2026-05-22 17:43:22 +02:00
Gilles Soulier c26584e4ae chore: gitignore agent/target et artefacts de build 2026-05-22 17:16:14 +02:00
Gilles SoulierandClaude Sonnet 4.6 a19705ffda fix(dashboard): XSS escaping, ResizeObserver leak, WS reconnect timer
- Ajout de esc() dans api.js pour échapper les valeurs serveur avant injection innerHTML
- Application de esc() sur hostname, ip et agentId dans grid.js et popups.js
- Fix fuite mémoire ResizeObserver dans showDetail : déconnexion avant recréation (_resizeObs)
- Fix WebSocket reconnect : clearTimeout avant setTimeout pour éviter les timers concurrents (_reconnectTimer)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:46:37 +02:00
Gilles Soulier 555ddc3556 feat(dashboard): app principale WebSocket + orchestration 2026-05-22 12:43:33 +02:00
Gilles Soulier 182d520675 feat(dashboard): popups détail, config agent, SMART, config serveur 2026-05-22 12:43:15 +02:00
Gilles Soulier 8cd18b14b2 feat(dashboard): rendu grille + tuiles dynamiques 2026-05-22 12:41:26 +02:00
Gilles Soulier 5f7cf9f837 feat(dashboard): courbes SVG sparklines 2026-05-22 12:40:55 +02:00
Gilles Soulier 4cfa628036 feat(dashboard): client API REST 2026-05-22 12:40:39 +02:00
Gilles SoulierandClaude Sonnet 4.6 96b9885fb3 fix(dashboard): tokens glow, scroll scfg-body, déduplication polices
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:36:54 +02:00
Gilles Soulier 4265b77996 fix(dashboard): retirer user-select:none de .f-mode 2026-05-22 12:34:26 +02:00
Gilles SoulierandClaude Sonnet 4.6 9e36f03a5c feat(dashboard): structure HTML + CSS complet
Ajout de la structure de base du dashboard Nanometrics :
- index.html avec header, grille agents, footer et 4 popups (détail, config agent, config serveur, SMART)
- css/app.css avec design system complet (tokens dark/light, composants, animations)
- Polices locales : Inter, JetBrains Mono, Share Tech Mono (woff2)
- Font Awesome 6.5.1 en local (vendor/fontawesome)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:33:02 +02:00
Gilles Soulier 544989542f agent + server go 2026-05-22 12:25:31 +02:00
Gilles SoulierandClaude Sonnet 4.6 feba5d6b93 fix(server): race WebSocket, SVG refusé, rows.Err, time.NewTicker
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:18:25 +02:00
Gilles SoulierandClaude Sonnet 4.6 c0c7152b47 feat(server): main.go assemblage complet + Docker + Nginx
- Assemble tous les packages (config, db, handlers, transport, websocket, prometheus)
- Boucle de rétention et détection offline toutes les minutes
- Routage REST /api/agents/, /api/config, /metrics, /ws
- Dockerfile multi-stage CGO_ENABLED=0 (alpine:3.19)
- docker-compose.yml avec service server + dashboard Nginx
- nginx.conf avec proxy WebSocket et fallback SPA

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:13:10 +02:00
Gilles SoulierandClaude Sonnet 4.6 262413e2e3 feat(server): handlers REST + transport MQTT
Ajout des handlers HTTP (agents, métriques historique, config agent/serveur, icônes upload/get) et du client MQTT serveur avec subscribe automatique et PushConfig.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:10:34 +02:00
Gilles SoulierandClaude Sonnet 4.6 f5219f3c68 feat(server): UDP listener, hub WebSocket, Gauges Prometheus
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:08:11 +02:00
Gilles SoulierandClaude Sonnet 4.6 bceee08ce4 feat(server): DB SQLite CRUD agents/metrics/config/icons + tests
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:06:11 +02:00
Gilles SoulierandClaude Sonnet 4.6 2aa0c3be86 feat(server): go.mod + config + modèles de données
Initialise le module Go github.com/user/nanometrics/server avec toutes
les dépendances (SQLite, gorilla/websocket, paho.mqtt, prometheus,
imaging). Ajoute config.go (Load/Default via env vars) et models.go
(AgentMetrics, SmartMetrics, Agent, AgentConfig, ServerConfig, WSMessage).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 12:03:47 +02:00
100 changed files with 23461 additions and 149 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"
+18
View File
@@ -1 +1,19 @@
repo.md
# Rust build artifacts
agent/target/
# Go build artifacts
server/tmp/
server/*.test
# OS
.DS_Store
Thumbs.db
# 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
@@ -1 +0,0 @@
{"type":"server-started","port":55731,"host":"0.0.0.0","url_host":"10.0.0.50","url":"http://10.0.0.50:55731","screen_dir":"/home/gilles/projects/nano_metrics/.superpowers/brainstorm/599687-1779425985/content","state_dir":"/home/gilles/projects/nano_metrics/.superpowers/brainstorm/599687-1779425985/state"}
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1779431805976}
@@ -24,3 +24,4 @@
{"type":"screen-added","file":"/home/gilles/projects/nano_metrics/.superpowers/brainstorm/599687-1779425985/content/layout-v7.html"}
{"type":"screen-added","file":"/home/gilles/projects/nano_metrics/.superpowers/brainstorm/599687-1779425985/content/layout-v8.html"}
{"type":"screen-added","file":"/home/gilles/projects/nano_metrics/.superpowers/brainstorm/599687-1779425985/content/layout-v9.html"}
{"type":"server-stopped","reason":"idle timeout"}
+190
View File
@@ -0,0 +1,190 @@
# Nanometrics
Système client-serveur de surveillance matérielle (CPU, RAM, disque) conçu pour une empreinte quasi nulle sur les machines Debian.
## Architecture
```
┌─────────────────┐ UDP / MQTT ┌──────────────────────────┐
│ Agent Rust │ ─────────────────────▶ │ Serveur Go │
│ ~1 Mo binaire │ │ SQLite · Prometheus │
│ systemd │ │ REST API · WebSocket │
└─────────────────┘ └──────────┬───────────────┘
│ HTTP
┌──────────▼───────────────┐
│ Dashboard Nginx │
│ HTML/CSS/JS · temps réel │
└──────────────────────────┘
```
## Fonctionnalités
- **Métriques collectées** : CPU, RAM (libre/utilisée), disque (total/libre/utilisé), uptime, réseau, température, SMART (si `smartctl` disponible)
- **Double protocole** : UDP (fire-and-forget) et/ou MQTT (bidirectionnel, compatible Home Assistant auto-discovery)
- **Temps réel** : WebSocket — mise à jour des tuiles sans rechargement
- **Historique** : SQLite avec rétention configurable (7 à 365 jours)
- **Dashboard interactif** : grille responsive, popup détail avec courbes, configuration par agent depuis l'UI
- **Empreinte agent minimale** : pas de runtime async (pas de Tokio), boucle mono-thread, `sysinfo` sans threads de fond
- **Sécurité systemd** : `DynamicUser=yes`, `ProtectSystem=strict`, `ProtectHome=read-only`
---
## Installation de l'agent Rust
### Prérequis
```bash
# Rust (si non installé)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
```
### Compilation
```bash
cargo build --release --manifest-path agent/Cargo.toml
# Binaire produit : agent/target/release/nanometrics-agent (~1 Mo)
```
### Configuration
Créer `/etc/nanometrics/config.toml` :
```toml
[server]
ip = "10.0.0.50" # IP du serveur Go
port = 9999 # Port UDP du serveur
[mqtt]
enabled = false
host = "10.0.0.3"
port = 1883
topic_base = "nanometrics/agents"
auto_discovery = true
birth_message = true
last_will = true
[metrics.cpu]
enabled = true
udp = true
mqtt = false
[metrics.memory]
enabled = true
udp = true
mqtt = false
[metrics.disk]
enabled = true
udp = true
mqtt = false
[metrics.smart]
enabled = true # nécessite smartctl installé
udp = true
mqtt = false
[metrics.uptime]
enabled = true
udp = true
mqtt = false
[metrics.network]
enabled = true
udp = true
mqtt = false
[metrics.temperature]
enabled = true
udp = true
mqtt = false
```
### Déploiement systemd
```bash
# Copier le binaire
sudo cp agent/target/release/nanometrics-agent /usr/local/bin/
# Créer le répertoire de config
sudo mkdir -p /etc/nanometrics
sudo cp agent/config.toml /etc/nanometrics/config.toml
# Installer le service
sudo cp deploy/nanometrics-agent.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now nanometrics-agent
# Vérifier
sudo systemctl status nanometrics-agent
journalctl -u nanometrics-agent -f
```
Pour SMART, installer `smartmontools` sur la machine cible :
```bash
sudo apt install smartmontools
```
---
## Installation du serveur Go + dashboard
### Prérequis
- Docker et Docker Compose
### Lancement
```bash
cd server
docker compose up -d
```
Le dashboard est disponible sur **http://&lt;ip-serveur&gt;** (port 80).
### Variables d'environnement
| Variable | Défaut | Description |
|----------|--------|-------------|
| `UDP_ADDR` | `0.0.0.0:9999` | Adresse d'écoute UDP |
| `HTTP_ADDR` | `0.0.0.0:8080` | Adresse du serveur HTTP |
| `DB_PATH` | `/data/nanometrics.db` | Chemin de la base SQLite |
| `MQTT_BROKER` | `tcp://10.0.0.3:1883` | Adresse du broker MQTT |
| `MQTT_TOPIC_BASE` | `nanometrics/agents` | Topic MQTT de base |
### Endpoints exposés
| Endpoint | Description |
|----------|-------------|
| `GET /api/agents` | Liste de tous les agents |
| `GET /api/agents/{id}/history` | Historique des métriques |
| `GET /api/agents/{id}/config` | Configuration d'un agent |
| `PUT /api/agents/{id}/config` | Modifier la config d'un agent |
| `POST /api/agents/{id}/icon` | Upload d'icône (JPG/PNG/WEBP) |
| `GET /api/config` | Configuration serveur |
| `PUT /api/config` | Modifier la configuration serveur |
| `GET /metrics` | Métriques Prometheus |
| `WS /ws` | WebSocket temps réel |
---
## Commandes de développement
```bash
# Agent Rust — build, tests, lint
cargo build --release --manifest-path agent/Cargo.toml
cargo test --manifest-path agent/Cargo.toml
cargo clippy --manifest-path agent/Cargo.toml
# Serveur Go — tests, vet
go test ./server/...
go vet ./server/...
# Serveur Go — lancement local (sans Docker)
cd server
DB_PATH=/tmp/nm.db HTTP_ADDR=0.0.0.0:8080 UDP_ADDR=0.0.0.0:9999 go run .
# Simuler un agent avec netcat
echo '{"hostname":"test-01","ip":"127.0.0.1","status":"online","cpu_percent":42.5,"memory_used":3000000000,"memory_total":8000000000,"hdd_used":60000000000,"hdd_total":200000000000,"uptime":86400}' \
| nc -u 127.0.0.1 9999
```
+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]]
name = "core-foundation"
version = "0.10.1"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
@@ -65,7 +65,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -97,21 +97,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "futures-core"
version = "0.3.32"
@@ -142,6 +127,17 @@ dependencies = [
"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]]
name = "getrandom"
version = "0.4.2"
@@ -247,13 +243,14 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
dependencies = [
"libc",
"wasi",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "nanometrics-agent"
version = "0.1.0"
version = "0.1.6"
dependencies = [
"libc",
"rumqttc",
"serde",
"serde_json",
@@ -262,23 +259,6 @@ dependencies = [
"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]]
name = "ntapi"
version = "0.4.3"
@@ -294,48 +274,11 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "openssl-probe"
version = "0.2.1"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
[[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",
]
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "pin-project-lite"
@@ -343,12 +286,6 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -383,6 +320,20 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "rumqttc"
version = "0.24.0"
@@ -393,10 +344,12 @@ dependencies = [
"flume",
"futures-util",
"log",
"native-tls",
"rustls-native-certs",
"rustls-pemfile",
"rustls-webpki",
"thiserror",
"tokio",
"tokio-native-tls",
"tokio-rustls",
]
[[package]]
@@ -409,7 +362,63 @@ dependencies = [
"errno",
"libc",
"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]]
@@ -418,7 +427,7 @@ version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
dependencies = [
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -429,9 +438,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags",
"core-foundation",
@@ -527,7 +536,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -539,6 +548,12 @@ dependencies = [
"lock_api",
]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
name = "syn"
version = "2.0.117"
@@ -571,10 +586,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -609,7 +624,7 @@ dependencies = [
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
"windows-sys 0.61.2",
]
[[package]]
@@ -624,12 +639,13 @@ dependencies = [
]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
name = "tokio-rustls"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f"
dependencies = [
"native-tls",
"rustls",
"rustls-pki-types",
"tokio",
]
@@ -687,10 +703,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "vcpkg"
version = "0.2.15"
name = "untrusted"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "wasi"
@@ -797,6 +813,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "windows-sys"
version = "0.61.2"
@@ -973,6 +998,12 @@ dependencies = [
"wasmparser",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zmij"
version = "1.0.21"
+3 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "nanometrics-agent"
version = "0.1.0"
version = "0.1.10"
edition = "2021"
[lib]
@@ -19,10 +19,11 @@ codegen-units = 1
[dependencies]
sysinfo = { version = "0.30", default-features = false }
libc = "0.2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
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]
tempfile = "3"
+24
View File
@@ -13,8 +13,12 @@ pub struct Config {
pub struct ServerConfig {
pub ip: String,
pub port: u16,
#[serde(default = "default_iperf3_port")]
pub iperf3_port: u16,
}
fn default_iperf3_port() -> u16 { 5201 }
#[derive(Deserialize, Debug, Clone, Default)]
pub struct ProtocolsConfig {
#[serde(default)]
@@ -90,6 +94,26 @@ pub struct MetricsConfig {
pub temperature: MetricProto,
#[serde(default)]
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)]
+138 -12
View File
@@ -1,18 +1,75 @@
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::sync::mpsc;
use std::sync::atomic::{AtomicBool, Ordering};
static RUNNING: AtomicBool = AtomicBool::new(true);
extern "C" fn handle_signal(_: libc::c_int) {
RUNNING.store(false, Ordering::Relaxed);
}
fn physical_ipv4_addrs() -> Vec<String> {
let mut result = Vec::new();
unsafe {
let mut ifap = std::ptr::null_mut::<libc::ifaddrs>();
if libc::getifaddrs(&mut ifap) != 0 { return result; }
let mut ifa = ifap;
while !ifa.is_null() {
let flags = (*ifa).ifa_flags as i32;
let up = flags & libc::IFF_UP as i32 != 0;
let loopback = flags & libc::IFF_LOOPBACK as i32 != 0;
let pointop = flags & libc::IFF_POINTOPOINT as i32 != 0;
if !up || loopback || pointop { ifa = (*ifa).ifa_next; continue; }
let name = std::ffi::CStr::from_ptr((*ifa).ifa_name)
.to_string_lossy().into_owned();
// Type 1 = ARPHRD_ETHER (Ethernet + WiFi), exclut WireGuard (65534), tunnels, etc.
let itype: u32 = std::fs::read_to_string(
format!("/sys/class/net/{}/type", name))
.ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0);
if itype != 1 { ifa = (*ifa).ifa_next; continue; }
// Exclut bridges et interfaces Docker par nom
let is_virtual = name.starts_with("br-") || name.starts_with("docker")
|| name.starts_with("virbr") || name.starts_with("veth");
if is_virtual { ifa = (*ifa).ifa_next; continue; }
if let Some(addr) = (*ifa).ifa_addr.as_ref() {
if addr.sa_family as i32 == libc::AF_INET {
let sin = addr as *const _ as *const libc::sockaddr_in;
let b = (*sin).sin_addr.s_addr.to_ne_bytes();
result.push(format!("{}.{}.{}.{}", b[0], b[1], b[2], b[3]));
}
}
ifa = (*ifa).ifa_next;
}
libc::freeifaddrs(ifap);
}
result
}
fn get_local_ip(server_ip: &str) -> String {
let physical = physical_ipv4_addrs();
fn get_local_ip() -> String {
use std::net::UdpSocket;
if let Ok(s) = UdpSocket::bind("0.0.0.0:0") {
if s.connect("8.8.8.8:80").is_ok() {
if let Ok(addr) = s.local_addr() {
return addr.ip().to_string();
for target in &[format!("{}:80", server_ip), "8.8.8.8:80".to_string()] {
if let Ok(s) = UdpSocket::bind("0.0.0.0:0") {
if s.connect(target.as_str()).is_ok() {
if let Ok(addr) = s.local_addr() {
let ip = addr.ip().to_string();
// N'accepte que si c'est une vraie interface physique
if ip != "0.0.0.0" && physical.contains(&ip) {
return ip;
}
}
}
}
}
"0.0.0.0".to_string()
// Fallback : première IP physique disponible
physical.into_iter().next().unwrap_or_else(|| "0.0.0.0".to_string())
}
fn apply_config_update(cfg: &mut config::Config, data: &[u8]) {
@@ -30,11 +87,10 @@ fn main() {
.expect("Impossible de charger config.toml");
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 networks = Networks::new_with_refreshed_list();
let mut disks = Disks::new_with_refreshed_list();
let mut components = Components::new_with_refreshed_list();
let udp_sender = if cfg.protocols.udp.enabled {
@@ -50,12 +106,38 @@ fn main() {
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_medium = Instant::now();
let mut first_medium = 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();
while let Ok(transport::mqtt::MqttIncoming::ConfigUpdate(data)) = incoming_rx.try_recv() {
@@ -65,10 +147,35 @@ fn main() {
sys.refresh_cpu_usage();
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 {
hostname: hostname.clone(),
ip: ip.clone(),
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()
};
@@ -102,9 +209,8 @@ fn main() {
}
if first_slow || now.duration_since(last_slow).as_secs() >= 60 {
disks.refresh();
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_free = Some(free);
m.hdd_total = Some(total);
@@ -135,4 +241,24 @@ fn main() {
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) {
for disk in disks.list() {
let mount = disk.mount_point().to_string_lossy();
if mount == "/" {
let total = disk.total_space();
let free = disk.available_space();
let used = total.saturating_sub(free);
return (used, free, total);
}
/// Retourne (used, free, total) en octets pour le système de fichiers racine "/".
/// Utilise statvfs() directement pour correspondre exactement aux chiffres de `df` :
/// - total = f_blocks × f_frsize
/// - used = (f_blocks f_bfree) × f_frsize (blocs effectivement écrits)
/// - free = f_bavail × f_frsize (disponible pour utilisateurs non-root)
pub fn get() -> (u64, u64, u64) {
let path = b"/\0";
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 total = disk.total_space();
let free = disk.available_space();
return (total.saturating_sub(free), free, total);
}
(0, 0, 0)
let stat = unsafe { stat.assume_init() };
let bsize = stat.f_frsize as u64;
let total = stat.f_blocks.saturating_mul(bsize);
let used = stat.f_blocks.saturating_sub(stat.f_bfree).saturating_mul(bsize);
let free = stat.f_bavail.saturating_mul(bsize);
(used, free, total)
}
#[cfg(test)]
mod tests {
use super::*;
use sysinfo::Disks;
#[test]
fn test_disk_coherent() {
let disks = Disks::new_with_refreshed_list();
let (used, free, total) = get(&disks);
let (used, free, total) = get();
eprintln!("résultat statvfs : used={used} free={free} total={total}");
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 disk;
pub mod hardware;
pub mod memory;
pub mod network;
pub mod network_info;
pub mod smart;
pub mod temperature;
pub mod uptime;
+105
View File
@@ -0,0 +1,105 @@
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 {
// Type 1 = ARPHRD_ETHER (Ethernet + WiFi). WireGuard = 65534, tunnels = autres.
let itype: u32 = std::fs::read_to_string(format!("/sys/class/net/{}/type", name))
.ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0);
if itype != 1 { return false; }
// Exclut bridges et interfaces Docker par nom (type 1 aussi)
!name.starts_with("br-") && !name.starts_with("docker")
&& !name.starts_with("virbr") && !name.starts_with("veth")
}
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()
}
+41 -11
View File
@@ -1,5 +1,4 @@
use serde::Deserialize;
use crate::payload::SmartMetrics;
#[derive(Deserialize)]
struct SmartJson {
@@ -42,7 +41,7 @@ pub fn is_available() -> bool {
.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 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,
temperature,
reallocated_sectors: reallocated,
@@ -80,19 +80,49 @@ 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() {
return None;
}
for dev in &["/dev/sda", "/dev/nvme0"] {
let output = std::process::Command::new("smartctl")
.args(["-j", dev])
.output()
.ok()?;
let mut devs: Vec<String> = std::fs::read_dir("/sys/block")
.into_iter()
.flatten()
.flatten()
.map(|e| e.file_name().into_string().unwrap_or_default())
.filter_map(|n| {
if n.starts_with("sd") {
Some(format!("/dev/{}", n))
} else if n.starts_with("nvme") {
// /sys/block a nvme0n1 (namespace) → extraire le contrôleur nvme0
// rfind('n') trouve le dernier 'n' séparateur namespace, ex: nvme0[n]1
let ctrl = if let Some(pos) = n.rfind('n')
.filter(|&p| p >= 5 && n[p+1..].chars().all(|c| c.is_ascii_digit())) {
&n[..pos]
} else {
&n // déjà un nom de contrôleur (rare)
};
Some(format!("/dev/{}", ctrl))
} else {
None
}
})
.collect::<std::collections::HashSet<_>>()
.into_iter()
.collect();
devs.sort();
let mut results = Vec::new();
for dev in &devs {
let Ok(output) = std::process::Command::new("smartctl")
.args(["-a", "-j", dev])
.output() else { continue };
let json = String::from_utf8_lossy(&output.stdout);
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 ip: String,
pub status: String,
#[serde(default)]
pub version: String,
pub cpu_percent: Option<f32>,
pub memory_used: Option<u64>,
pub memory_free: Option<u64>,
@@ -16,11 +18,36 @@ pub struct AgentMetrics {
pub network_rx: Option<u64>,
pub network_tx: Option<u64>,
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)]
pub struct SmartMetrics {
#[serde(default)]
pub device: String,
pub passed: bool,
pub temperature: 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 _ = 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,
smart: None,
status: "online".to_string(),
version: "0.0.0".to_string(),
};
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("\"hostname\":\"srv-01\""));
@@ -31,13 +32,14 @@ fn test_serialize_avec_smart() {
let m = AgentMetrics {
hostname: "srv-01".to_string(),
ip: "10.0.0.11".to_string(),
smart: Some(SmartMetrics {
smart: Some(vec![SmartMetrics {
device: "sda".to_string(),
passed: true,
temperature: Some(34),
reallocated_sectors: Some(0),
power_on_hours: Some(4213),
wear_level: Some(98),
}),
}]),
status: "online".to_string(),
..Default::default()
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+296
View File
@@ -0,0 +1,296 @@
/* Variables globales (indépendantes du thème) */
:root {
--font-ui:'Inter',system-ui,sans-serif;
--font-mono:'JetBrains Mono',monospace;
--font-terminal:'Share Tech Mono',monospace;
}
/* Polices locales */
@font-face {
font-family: 'Inter';
src: url('../fonts/inter.woff2') format('woff2');
font-weight: 100 900;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('../fonts/jetbrains-mono.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('../fonts/jetbrains-mono-bold.woff2') format('woff2');
font-weight: 700;
font-display: swap;
}
@font-face {
font-family: 'Share Tech Mono';
src: url('../fonts/share-tech-mono.woff2') format('woff2');
font-weight: 400;
font-display: swap;
}
/* Tokens design system */
:root[data-theme="dark"] {
--accent:#fe8019;--accent-soft:#d65d0e;--accent-glow:rgba(254,128,25,.28);--accent-tint:rgba(254,128,25,.1);
--bg-0:#1d1813;--bg-1:#2a231d;--bg-2:#32291f;--bg-3:#3c332a;--bg-4:#4a3f33;--bg-5:#57493c;
--ink-1:#f2e5c7;--ink-2:#d5c4a1;--ink-3:#a89984;--ink-4:#7c6f64;
--ok:#4dbb26;--warn:#fabd2f;--err:#fb4934;--blue:#3db0d1;--purple:#c882c8;
--border-1:rgba(255,255,255,.06);--border-2:rgba(255,255,255,.12);--border-3:rgba(255,255,255,.26);
--tile-3d:0 1px 0 rgba(255,255,255,.08) inset,0 -1px 0 rgba(0,0,0,.3) inset,0 6px 20px rgba(0,0,0,.5);
--tile-press:inset 0 2px 8px rgba(0,0,0,.5),inset 0 1px 3px rgba(0,0,0,.4);
--hover-glow:0 0 0 1px var(--accent-soft),0 0 24px var(--accent-glow),0 6px 20px rgba(0,0,0,.5);
--warn-glow:rgba(250,189,47,.22);--err-glow:rgba(251,73,52,.25);
}
:root[data-theme="light"] {
--accent:#af3a03;--accent-soft:#d65d0e;--accent-glow:rgba(175,58,3,.18);--accent-tint:rgba(175,58,3,.08);
--bg-0:#d5c4a1;--bg-1:#ebdbb2;--bg-2:#d5c4a1;--bg-3:#bdae93;--bg-4:#a89984;--bg-5:#928374;
--ink-1:#3c3836;--ink-2:#504945;--ink-3:#665c54;--ink-4:#7c6f64;
--ok:#3c911c;--warn:#b57614;--err:#9d0006;--blue:#2d82a3;--purple:#8c468c;
--border-1:rgba(0,0,0,.08);--border-2:rgba(0,0,0,.15);--border-3:rgba(0,0,0,.3);
--tile-3d:0 1px 0 rgba(255,255,255,.55) inset,0 -1px 0 rgba(0,0,0,.08) inset,0 4px 14px rgba(0,0,0,.13);
--tile-press:inset 0 2px 6px rgba(0,0,0,.2);
--hover-glow:0 0 0 1px var(--accent-soft),0 0 18px var(--accent-glow),0 4px 14px rgba(0,0,0,.13);
--warn-glow:rgba(181,118,20,.22);--err-glow:rgba(157,0,6,.25);
}
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-size:13px;
height:100vh;display:flex;flex-direction:column;overflow:hidden;transition:background .2s,color .2s}
/* TOOLTIP global position:fixed */
#tooltip{position:fixed;z-index:9999;pointer-events:none;background:var(--bg-0);color:var(--ink-1);
border:1px solid var(--border-3);border-radius:5px;padding:4px 9px;font-size:11px;
font-family:var(--font-ui);white-space:nowrap;opacity:0;transition:opacity .12s;
box-shadow:0 4px 12px rgba(0,0,0,.4)}
#tooltip.show{opacity:1}
/* HEADER */
.header{background:var(--bg-2);border-bottom:1px solid var(--border-2);padding:0 20px;
height:48px;display:flex;align-items:center;gap:12px;flex-shrink:0}
.logo{display:flex;align-items:center;gap:8px}
.logo-led{width:9px;height:9px;border-radius:50%;background:var(--accent);
box-shadow:0 0 8px var(--accent-glow);animation:blink 2s infinite}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.4}}
.logo-name{font-weight:700;font-size:14px;letter-spacing:.05em;font-family:var(--font-terminal)}
.logo-ver{font-size:10px;color:var(--ink-4);font-family:var(--font-terminal)}
.h-sep{width:1px;height:24px;background:var(--border-2)}
.h-spacer{flex:1}
.h-stats{display:flex;gap:14px}
.h-stat{display:flex;align-items:center;gap:5px}
.h-stat .lbl{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.06em}
.h-stat .val{font-family:var(--font-mono);font-weight:700;font-size:13px}
.c-ok{color:var(--ok)}.c-warn{color:var(--warn)}.c-err{color:var(--err)}.c-n{color:var(--ink-2)}
.hbtn{width:34px;height:34px;border-radius:8px;border:1px solid var(--border-2);background:var(--bg-3);
color:var(--ink-2);font-size:14px;display:flex;align-items:center;justify-content:center;
cursor:pointer;user-select:none;transition:background .12s,color .12s,transform .08s,box-shadow .08s}
.hbtn:hover{background:var(--bg-4);color:var(--accent)}
.hbtn:active{transform:translateY(1px) scale(.96);box-shadow:var(--tile-press)}
.hbtn.active-btn{background:var(--accent);color:var(--bg-0);border-color:var(--accent-soft)}
/* GRILLE */
.main{flex:1;padding:14px 16px;overflow-y:auto}
.agents-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--tile-min,220px),1fr));gap:10px}
.tile{background:var(--bg-3);border-radius:10px;padding:12px 14px;border:1px solid var(--border-1);
box-shadow:var(--tile-3d);cursor:pointer;display:flex;flex-direction:column;gap:9px;
transition:box-shadow .15s,transform .08s,border-color .15s}
.tile:hover{box-shadow:var(--hover-glow);border-color:var(--accent-soft)}
.tile:active{transform:translateY(2px) scale(.99);box-shadow:var(--tile-press)}
.tile.t-warn{border-color:rgba(250,189,47,.3)}
.tile.t-warn:hover{border-color:var(--warn);box-shadow:0 0 0 1px var(--warn),0 0 22px var(--warn-glow),0 6px 20px rgba(0,0,0,.5)}
.tile.t-err{border-color:rgba(251,73,52,.35)}
.tile.t-err:hover{border-color:var(--err);box-shadow:0 0 0 1px var(--err),0 0 22px var(--err-glow),0 6px 20px rgba(0,0,0,.5)}
.tile.t-off{opacity:.5;cursor:default}.tile.t-off:hover,.tile.t-off:active{box-shadow:var(--tile-3d);border-color:var(--border-1);transform:none}
.tile-head{display:flex;align-items:center;gap:8px;user-select:none}
.t-icon{width:28px;height:28px;border-radius:7px;background:var(--bg-4);display:flex;align-items:center;
justify-content:center;color:var(--accent);font-size:13px;flex-shrink:0;overflow:hidden}
.t-icon img{width:100%;height:100%;object-fit:cover}
.t-names{flex:1;min-width:0}
.t-host{font-weight:600;font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.t-ip{font-family:var(--font-mono);font-size:10px;color:var(--ink-4)}
.t-led{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.s-ok{background:var(--ok);box-shadow:0 0 6px var(--ok)}
.s-warn{background:var(--warn);box-shadow:0 0 6px var(--warn);animation:blink 1.5s infinite}
.s-err{background:var(--err);box-shadow:0 0 8px var(--err);animation:blink 1s infinite}
.s-off{background:var(--ink-4)}
.tile-gauges{display:flex;flex-direction:column;gap:5px}
.g-row{display:flex;align-items:center;gap:7px}
.g-ico{width:18px;height:18px;display:flex;align-items:center;justify-content:center;
font-size:11px;color:var(--ink-3);flex-shrink:0;cursor:help}
.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.w{background:var(--warn)}.g-fill.e{background:var(--err)}
.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);
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{background:var(--bg-0);border-top:1px solid var(--border-2);height:26px;
display:flex;align-items:center;font-family:var(--font-terminal);font-size:11px;
color:var(--ink-4);flex-shrink:0}
.f-mode{background:var(--accent);color:var(--bg-0);padding:0 12px;height:100%;
display:flex;align-items:center;font-weight:700;letter-spacing:.04em}
.f-cell{padding:0 12px;border-right:1px solid var(--border-1);display:flex;align-items:center;gap:5px;height:100%}
.f-val{font-family:var(--font-mono);color:var(--ink-2)}.f-val.w{color:var(--warn)}
.f-minibar{width:36px;height:4px;border-radius:2px;background:var(--bg-3);overflow:hidden}
.f-minifill{height:100%;border-radius:2px;background:var(--ok)}.f-minifill.w{background:var(--warn)}
.f-spacer{flex:1}.f-right{padding:0 12px;display:flex;align-items:center;gap:6px;color:var(--ink-3)}
.f-time{font-family:var(--font-mono);color:var(--ink-2)}
/* OVERLAY + POPUP */
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.65);z-index:100;display:flex;
align-items:center;justify-content:center;backdrop-filter:blur(2px)}
.popup{background:var(--bg-2);border:1px solid var(--border-3);border-radius:12px;
box-shadow:0 24px 64px rgba(0,0,0,.7);display:flex;flex-direction:column;overflow:hidden}
.pop-close{width:28px;height:28px;border-radius:6px;background:var(--bg-5);color:var(--ink-3);
display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:12px;
border:1px solid var(--border-1);user-select:none;transition:background .12s,color .12s,transform .08s}
.pop-close:hover{background:var(--err);color:#fff}
.pop-close:active{transform:translateY(1px) scale(.93)}
.btn{padding:6px 14px;border-radius:8px;border:1px solid var(--border-2);background:var(--bg-4);
color:var(--ink-2);font-size:12px;font-family:var(--font-ui);cursor:pointer;
display:flex;align-items:center;gap:6px;user-select:none;transition:background .1s,transform .08s}
.btn:hover{background:var(--bg-5)}.btn:active{transform:translateY(1px) scale(.97)}
.btn.primary{background:var(--accent);color:var(--bg-0);border-color:var(--accent-soft);font-weight:600}
.btn.primary:hover{background:var(--accent-soft)}
/* Popup détail agent */
#popup-detail{width:560px;max-width:96vw;max-height:92vh;resize:both;overflow:hidden;
min-width:400px;min-height:320px}
#popup-detail .pop-body{overflow-y:auto;flex:1}
.pop-head{background:var(--bg-3);padding:14px 18px;border-bottom:1px solid var(--border-2);
display:flex;align-items:center;gap:12px;flex-shrink:0}
.agent-icon-wrap{position:relative;width:44px;height:44px;border-radius:10px;flex-shrink:0;
cursor:pointer;overflow:hidden;background:var(--bg-4);display:flex;
align-items:center;justify-content:center;color:var(--accent);font-size:18px;
border:2px solid var(--border-2);transition:border-color .15s}
.agent-icon-wrap:hover{border-color:var(--accent)}
.agent-icon-overlay{position:absolute;inset:0;background:rgba(0,0,0,.6);display:flex;
flex-direction:column;align-items:center;justify-content:center;
gap:2px;opacity:0;transition:opacity .15s;font-size:10px;color:#fff}
.agent-icon-wrap:hover .agent-icon-overlay{opacity:1}
.pop-host{font-weight:700;font-size:15px}.pop-ip{font-family:var(--font-mono);font-size:11px;color:var(--ink-4)}
.pop-led{width:10px;height:10px;border-radius:50%;background:var(--ok);box-shadow:0 0 8px var(--ok);flex-shrink:0}
.pop-body{padding:16px 18px;display:flex;flex-direction:column;gap:14px}
.sec-title{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.08em;margin-bottom:8px}
.kpi-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:7px}
.kpi{background:var(--bg-3);border-radius:8px;padding:10px 12px;border:1px solid var(--border-1);box-shadow:var(--tile-3d)}
.kpi-lbl{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.06em}
.kpi-val{font-family:var(--font-mono);font-size:20px;font-weight:700;line-height:1.1;margin-top:2px}
.kpi-val .u{font-size:10px;color:var(--ink-3);font-weight:400}
.kpi-sub{font-size:10px;color:var(--ink-4);font-family:var(--font-mono);margin-top:2px}
.charts-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}
.chart-card{background:var(--bg-3);border-radius:8px;padding:10px 12px;border:1px solid var(--border-1)}
.chart-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
.chart-label{display:flex;align-items:center;gap:6px;font-size:10px;font-family:var(--font-terminal);color:var(--ink-3)}
.chart-cur{font-family:var(--font-mono);font-size:16px;font-weight:700}
.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-minmax{display:flex;justify-content:space-between;margin-top:3px;font-family:var(--font-mono);font-size:9px;color:var(--ink-4)}
.smart-pill{display:inline-flex;align-items:center;gap:3px;padding:1px 7px;border-radius:999px;
font-size:9px;font-family:var(--font-terminal);font-weight:700;border:1px solid;
cursor:pointer;user-select:none;flex-shrink:0;
transition:opacity .12s,transform .08s,box-shadow .12s}
.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{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-val{font-family:var(--font-mono);font-size:12px;color:var(--ink-2);margin-top:2px}
.proto-badge{display:inline-flex;align-items:center;gap:4px;padding:2px 7px;border-radius:999px;
font-size:10px;font-family:var(--font-terminal);font-weight:600}
.proto-badge.udp{background:rgba(61,176,209,.15);color:var(--blue);border:1px solid rgba(61,176,209,.3)}
.proto-badge.mqtt{background:rgba(200,130,200,.15);color:var(--purple);border:1px solid rgba(200,130,200,.3)}
.pop-foot{padding:10px 18px;border-top:1px solid var(--border-2);background:var(--bg-3);
display:flex;align-items:center;gap:8px;flex-shrink:0}
.pop-uptime{font-family:var(--font-terminal);font-size:11px;color:var(--ink-4);flex:1}
.btn-agent-cfg{width:34px;height:34px;border-radius:8px;border:1px solid var(--border-2);
background:var(--bg-4);color:var(--ink-3);font-size:15px;display:flex;
align-items:center;justify-content:center;cursor:pointer;user-select:none;
transition:background .12s,color .12s,transform .08s}
.btn-agent-cfg:hover{background:var(--bg-5);color:var(--accent)}
/* Métriques tableau 3 colonnes */
.metrics-table{display:flex;flex-direction:column;border:1px solid var(--border-2);border-radius:8px;overflow:hidden}
.metrics-header{display:grid;grid-template-columns:1fr 56px 56px;background:var(--bg-4);
padding:8px 12px;gap:4px;align-items:center}
.mh-label{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.06em}
.mh-proto{font-size:9px;font-family:var(--font-terminal);font-weight:700;text-align:center;
display:flex;flex-direction:column;align-items:center;gap:2px}
.mh-proto.udp{color:var(--blue)}.mh-proto.mqtt{color:var(--purple)}
.metric-row{display:grid;grid-template-columns:1fr 56px 56px;padding:8px 12px;gap:4px;
align-items:center;background:var(--bg-3);border-top:1px solid var(--border-1);transition:background .1s}
.metric-row:hover{background:var(--bg-4)}
.metric-cell{display:flex;align-items:center;gap:8px}
.metric-ico{font-size:13px;color:var(--ink-3);width:16px;text-align:center}
.metric-name{font-size:12px;color:var(--ink-2);font-family:var(--font-terminal)}
.metric-chk{display:flex;justify-content:center}
.cbox{width:20px;height:20px;border-radius:5px;border:2px solid var(--border-2);background:var(--bg-1);
display:flex;align-items:center;justify-content:center;cursor:pointer;font-size:11px;
color:transparent;transition:background .12s,border-color .12s,color .12s,transform .08s;
user-select:none;flex-shrink:0}
.cbox:hover{border-color:var(--border-3);transform:scale(1.08)}
.cbox.udp-on{background:rgba(61,176,209,.18);border-color:var(--blue);color:var(--blue)}
.cbox.mqtt-on{background:rgba(200,130,200,.18);border-color:var(--purple);color:var(--purple)}
/* Config serveur */
.scfg-body{padding:18px;display:flex;flex-direction:column;gap:14px;overflow-y:auto;flex:1;min-height:0}
.scfg-sec-title{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);
letter-spacing:.08em;padding-bottom:6px;border-bottom:1px solid var(--border-1)}
.scfg-row{display:flex;align-items:center;gap:10px}
.scfg-row>label{font-size:12px;color:var(--ink-3);width:110px;flex-shrink:0;font-family:var(--font-terminal)}
.scfg-slider{flex:1;accent-color:var(--accent)}
.scfg-val{font-family:var(--font-mono);font-size:12px;color:var(--ink-2);width:48px;text-align:right}
.scfg-select{flex:1;background:var(--bg-3);border:1px solid var(--border-2);border-radius:6px;
color:var(--ink-1);padding:6px 10px;font-size:12px}
.scfg-toggle-row{display:flex;align-items:center;justify-content:space-between;
padding:8px 10px;background:var(--bg-3);border-radius:7px;border:1px solid var(--border-1)}
.toggle{position:relative;width:34px;height:18px;flex-shrink:0}
.toggle input{opacity:0;width:0;height:0}
.toggle-slider{position:absolute;inset:0;border-radius:9px;background:var(--bg-4);
border:1px solid var(--border-2);cursor:pointer;transition:background .2s}
.toggle-slider::before{content:'';position:absolute;width:12px;height:12px;border-radius:50%;
background:var(--ink-4);top:2px;left:2px;transition:transform .2s,background .2s}
.toggle input:checked+.toggle-slider{background:rgba(254,128,25,.3);border-color:var(--accent)}
.toggle input:checked+.toggle-slider::before{transform:translateX(16px);background:var(--accent)}
/* SMART */
.smart-verdict{display:flex;align-items:center;gap:14px;background:rgba(77,187,38,.1);
border:1px solid rgba(77,187,38,.3);border-radius:10px;padding:14px 18px}
.si-val{font-family:var(--font-mono);font-size:18px;font-weight:700}
.si-val .u{font-size:11px;color:var(--ink-3);font-weight:400}
.si-desc{font-size:11px;color:var(--ink-3);margin-top:4px;line-height:1.4}
.attr-row{display:flex;align-items:center;gap:10px;padding:6px 10px;
border-radius:6px;background:var(--bg-3);border:1px solid var(--border-1)}
.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-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" }
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+190
View File
@@ -0,0 +1,190 @@
<!DOCTYPE html>
<html data-theme="dark" lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<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="css/app.css">
</head>
<body>
<div id="tooltip"></div>
<!-- HEADER -->
<div class="header">
<div class="logo">
<div class="logo-led"></div>
<span class="logo-name">NANOMETRICS</span>
<span class="logo-ver">v1.0</span>
</div>
<div class="h-sep"></div>
<div class="h-stats" id="h-stats">
<div class="h-stat"><span class="lbl">AGENTS</span><span class="val c-n" id="stat-total">0</span></div>
<div class="h-stat"><span class="lbl">OK</span><span class="val c-ok" id="stat-ok">0</span></div>
<div class="h-stat"><span class="lbl">WARN</span><span class="val c-warn" id="stat-warn">0</span></div>
<div class="h-stat"><span class="lbl">ERR</span><span class="val c-err" id="stat-err">0</span></div>
</div>
<div class="h-spacer"></div>
<div class="hbtn" id="btn-theme" onclick="App.toggleTheme()" data-tip="Thème clair / sombre">
<i class="fa-solid fa-moon" id="theme-icon"></i>
</div>
<div class="hbtn" id="btn-srvcfg" onclick="Popups.showSrvCfg()" data-tip="Configuration serveur / interface">
<i class="fa-solid fa-sliders"></i>
</div>
</div>
<!-- GRILLE -->
<div class="main"><div class="agents-grid" id="agents-grid"></div></div>
<!-- FOOTER -->
<div class="footer">
<div class="f-mode">LIVE</div>
<div class="f-cell"><i class="fa-solid fa-server" style="font-size:10px"></i><span>SERVEUR</span></div>
<div class="f-cell">
<i class="fa-solid fa-microchip" style="font-size:10px"></i>
<span class="f-val" id="srv-cpu"></span>
<div class="f-minibar"><div class="f-minifill" id="srv-cpu-bar"></div></div>
</div>
<div class="f-cell">
<i class="fa-solid fa-memory" style="font-size:10px"></i>
<span class="f-val" id="srv-mem"></span>
<div class="f-minibar"><div class="f-minifill" id="srv-mem-bar"></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-right">
<i class="fa-solid fa-rotate"></i>
<span>Actualisation : <span class="f-time" id="f-time"></span></span>
</div>
</div>
<!-- POPUP DÉTAIL AGENT -->
<div class="overlay" id="overlay-detail" style="display:none" onclick="if(event.target===this)Popups.hideDetail()">
<div class="popup" id="popup-detail" onclick="event.stopPropagation()">
<div class="pop-head">
<div class="agent-icon-wrap" id="pop-icon-wrap" data-tip="Changer l'icône">
<span id="pop-icon-fa"><i class="fa-solid fa-server"></i></span>
<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>
<input type="file" id="icon-upload" accept=".jpg,.jpeg,.png,.webp" style="display:none">
<div style="flex:1">
<div class="pop-host" id="pop-host"></div>
<div class="pop-ip" id="pop-ip"></div>
<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 · JPG PNG WEBP · max 128×128 px
</div>
</div>
<div class="pop-led" id="pop-led"></div>
<div class="pop-close" onclick="Popups.hideDetail()" data-tip="Fermer"><i class="fa-solid fa-xmark"></i></div>
</div>
<div class="pop-body" id="pop-body"></div>
<div class="pop-foot">
<span class="pop-uptime" id="pop-uptime"></span>
<span style="font-family:var(--font-terminal);font-size:9px;color:var(--ink-4);display:flex;align-items:center;gap:4px"
data-tip="Taille sauvegardée sur le serveur">
<i class="fa-solid fa-up-right-and-down-left-from-center"></i>Redimensionnable
</span>
<div class="btn-agent-cfg" onclick="Popups.showAgentCfg()" data-tip="Configurer l'agent">
<i class="fa-solid fa-gears"></i>
</div>
</div>
</div>
</div>
<!-- POPUP CONFIG AGENT -->
<div class="overlay" id="overlay-agentcfg" style="display:none;z-index:200" onclick="if(event.target===this)this.style.display='none'">
<div class="popup" id="popup-agentcfg" style="width:520px;max-width:96vw;max-height:90vh" onclick="event.stopPropagation()">
<div style="background:var(--bg-3);padding:14px 18px;border-bottom:1px solid var(--border-2);display:flex;align-items:center;gap:10px">
<div style="width:32px;height:32px;border-radius:8px;background:var(--bg-4);display:flex;align-items:center;justify-content:center;color:var(--accent);font-size:15px"><i class="fa-solid fa-gears"></i></div>
<div style="flex:1"><div style="font-weight:700;font-size:14px">Configuration de l'agent</div>
<div style="font-size:11px;color:var(--ink-4);font-family:var(--font-terminal)" id="agentcfg-sub"></div></div>
<div class="pop-close" onclick="this.closest('.overlay').style.display='none'" data-tip="Fermer"><i class="fa-solid fa-xmark"></i></div>
</div>
<div style="padding:18px;display:flex;flex-direction:column;gap:16px;overflow-y:auto;max-height:62vh" id="agentcfg-body"></div>
<div style="padding:12px 18px;border-top:1px solid var(--border-2);background:var(--bg-3);display:flex;align-items:center;gap:8px">
<div style="flex:1;display:flex;align-items:center;gap:6px;font-family:var(--font-terminal);font-size:11px;color:var(--ink-4)">
<div style="width:6px;height:6px;border-radius:50%;background:var(--ok)"></div>
<span>Config synchronisée avec l'agent</span>
</div>
<button class="btn" onclick="this.closest('.overlay').style.display='none'">Annuler</button>
<button class="btn primary" onclick="Popups.sendAgentConfig()"><i class="fa-solid fa-paper-plane"></i> Envoyer à l'agent</button>
</div>
</div>
</div>
<!-- POPUP CONFIG SERVEUR -->
<div class="overlay" id="overlay-srvcfg" style="display:none" onclick="if(event.target===this)Popups.hideSrvCfg()">
<div class="popup" id="popup-srvcfg" style="width:400px;max-width:96vw;max-height:88vh" onclick="event.stopPropagation()">
<div style="background:var(--bg-3);padding:14px 18px;border-bottom:1px solid var(--border-2);display:flex;align-items:center;gap:10px">
<div style="width:32px;height:32px;border-radius:8px;background:var(--accent);display:flex;align-items:center;justify-content:center;color:var(--bg-0);font-size:15px"><i class="fa-solid fa-sliders"></i></div>
<span style="flex:1;font-weight:700;font-size:14px">Configuration interface</span>
<div class="pop-close" onclick="Popups.hideSrvCfg()" data-tip="Fermer"><i class="fa-solid fa-xmark"></i></div>
</div>
<div class="scfg-body" id="srvcfg-body"></div>
<div style="padding:12px 18px;border-top:1px solid var(--border-2);background:var(--bg-3);display:flex;gap:8px;justify-content:flex-end">
<button class="btn" onclick="Popups.hideSrvCfg()">Annuler</button>
<button class="btn primary" onclick="Popups.saveSrvCfg()"><i class="fa-solid fa-floppy-disk"></i> Sauvegarder</button>
</div>
</div>
</div>
<!-- POPUP SMART -->
<div class="overlay" id="overlay-smart" style="display:none;z-index:300" onclick="if(event.target===this)this.style.display='none'">
<div class="popup" id="popup-smart" style="width:500px;max-width:96vw;max-height:88vh" onclick="event.stopPropagation()">
<div style="background:var(--bg-3);padding:14px 18px;border-bottom:1px solid var(--border-2);display:flex;align-items:center;gap:10px">
<div style="width:32px;height:32px;border-radius:8px;background:var(--bg-4);display:flex;align-items:center;justify-content:center;color:var(--ok);font-size:15px"><i class="fa-solid fa-shield-heart"></i></div>
<div style="flex:1"><div style="font-weight:700;font-size:14px">Santé du disque dur</div>
<div style="font-size:11px;color:var(--ink-4);font-family:var(--font-terminal)" id="smart-sub"></div></div>
<div class="pop-close" onclick="this.closest('.overlay').style.display='none'" data-tip="Fermer"><i class="fa-solid fa-xmark"></i></div>
</div>
<div style="padding:18px;display:flex;flex-direction:column;gap:16px;overflow-y:auto;max-height:70vh" id="smart-body"></div>
<div style="padding:12px 18px;border-top:1px solid var(--border-2);background:var(--bg-3);display:flex;align-items:center;gap:8px">
<span style="flex:1;font-size:10px;color:var(--ink-4);font-family:var(--font-terminal)">
<i class="fa-solid fa-circle-info" style="margin-right:4px"></i>Données via smartctl
</span>
<button class="btn primary" onclick="this.closest('.overlay').style.display='none'"><i class="fa-solid fa-xmark"></i> Fermer</button>
</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/charts.js"></script>
<script src="js/grid.js"></script>
<script src="js/popups.js"></script>
<script src="js/app.js"></script>
</body>
</html>
+55
View File
@@ -0,0 +1,55 @@
// Échappe les valeurs serveur avant injection dans innerHTML
function esc(s) {
if (s == null) return '—';
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
const API = (() => {
const BASE = ''; // même origine, proxy Nginx vers le serveur Go
async function get(path) {
const r = await fetch(BASE + path);
if (!r.ok) throw new Error(`GET ${path}: ${r.status}`);
return r.json();
}
async function put(path, body) {
const r = await fetch(BASE + path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
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) {
const r = await fetch(BASE + path, { method: 'POST', body: formData });
if (!r.ok) throw new Error(`POST ${path}: ${r.status}`);
}
return {
getAgents: () => get('/api/agents'),
getAgentHistory: (id, from, to) => get(`/api/agents/${id}/history?from=${from}&to=${to}`),
getAgentConfig: (id) => get(`/api/agents/${id}/config`),
putAgentConfig: (id, cfg) => put(`/api/agents/${id}/config`, cfg),
getServerConfig: () => get('/api/config'),
putServerConfig: (cfg) => put('/api/config', cfg),
uploadIcon: (id, file) => {
const fd = new FormData();
fd.append('icon', file);
return postForm(`/api/agents/${id}/icon`, fd);
},
deleteAgent: (id) => del(`/api/agents/${id}`),
iconUrl: (id) => `/api/agents/${id}/icon`,
};
})();
+138
View File
@@ -0,0 +1,138 @@
const App = (() => {
let _ws = null;
let _reconnectDelay = 1000;
let _reconnectTimer = null;
let _serverConfig = null;
// Tooltip global position:fixed
const tip = document.getElementById('tooltip');
let _tt;
document.addEventListener('mouseover', e => {
const el = e.target.closest('[data-tip]');
if (!el) return;
clearTimeout(_tt);
_tt = setTimeout(() => {
tip.textContent = el.dataset.tip;
tip.classList.add('show');
}, 120);
});
document.addEventListener('mousemove', e => {
if (!tip.classList.contains('show')) return;
const w = tip.offsetWidth, h = tip.offsetHeight;
let x = e.clientX - w / 2, y = e.clientY - h - 10;
x = Math.max(6, Math.min(x, window.innerWidth - w - 6));
if (y < 6) y = e.clientY + 18;
tip.style.left = x + 'px';
tip.style.top = y + 'px';
});
document.addEventListener('mouseout', e => {
if (!e.target.closest('[data-tip]')) return;
clearTimeout(_tt);
tip.classList.remove('show');
});
function toggleTheme() {
const h = document.documentElement;
h.dataset.theme = h.dataset.theme === 'dark' ? 'light' : 'dark';
document.getElementById('theme-icon').className =
h.dataset.theme === 'dark' ? 'fa-solid fa-moon' : 'fa-solid fa-sun';
}
function updateClock() {
document.getElementById('f-time').textContent =
new Date().toLocaleTimeString('fr-FR');
}
function updateServerStats(stats) {
const cpu = stats.cpu_percent ?? 0;
const memPct = stats.mem_total > 0 ? (stats.mem_used / stats.mem_total * 100) : 0;
const cpuEl = document.getElementById('srv-cpu');
const memEl = document.getElementById('srv-mem');
const cpuBar = document.getElementById('srv-cpu-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) {
cpuEl.textContent = cpu.toFixed(0) + '%';
cpuEl.className = 'f-val' + (cpu >= 70 ? ' w' : '');
}
if (cpuBar) {
cpuBar.style.width = cpu.toFixed(0) + '%';
cpuBar.className = 'f-minifill' + (cpu >= 70 ? ' w' : '');
}
if (memEl) {
memEl.textContent = memPct.toFixed(0) + '%';
memEl.className = 'f-val' + (memPct >= 70 ? ' w' : '');
}
if (memBar) {
memBar.style.width = memPct.toFixed(0) + '%';
memBar.className = 'f-minifill' + (memPct >= 70 ? ' w' : '');
}
}
function connectWS() {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
_ws = new WebSocket(`${proto}://${location.host}/ws`);
_ws.onopen = () => {
_reconnectDelay = 1000;
document.querySelector('.logo-led').style.animation = 'blink 2s infinite';
};
_ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'metrics_update') {
Grid.update(msg.agent_id, msg.data);
updateClock();
} else if (msg.type === 'server_stats') {
updateServerStats(msg.data);
} else if (msg.type === 'status_update') {
Grid.updateStatus(msg.agent_id, msg.data.status);
} else if (msg.type === 'agent_removed') {
Grid.removeAgent(msg.agent_id);
}
} catch {}
};
_ws.onclose = () => {
clearTimeout(_reconnectTimer);
_reconnectTimer = setTimeout(connectWS, _reconnectDelay);
_reconnectDelay = Math.min(_reconnectDelay * 2, 30000);
};
}
async function init() {
try {
_serverConfig = await API.getServerConfig();
if (_serverConfig.tile_min_width) {
document.documentElement.style.setProperty('--tile-min', _serverConfig.tile_min_width + 'px');
}
if (_serverConfig.font_size) {
document.documentElement.style.fontSize = _serverConfig.font_size + 'px';
}
if (_serverConfig.popup_detail_w && _serverConfig.popup_detail_h) {
const pd = document.getElementById('popup-detail');
pd.style.width = _serverConfig.popup_detail_w + 'px';
pd.style.height = _serverConfig.popup_detail_h + 'px';
}
} catch {}
try {
const agents = await API.getAgents();
Grid.refresh(agents);
} catch {}
connectWS();
updateClock();
setInterval(updateClock, 1000);
}
document.addEventListener('DOMContentLoaded', init);
return {
toggleTheme,
get serverConfig() { return _serverConfig; },
set serverConfig(v) { _serverConfig = v; },
};
})();
+46
View File
@@ -0,0 +1,46 @@
const Charts = (() => {
function makeCurve(pts, stroke, fill, w, h) {
if (!pts || pts.length < 2) return '';
const xs = pts.map((_, i) => (i / (pts.length - 1)) * w);
const ys = pts.map(v => h - (v / 100) * (h - 6) - 3);
const wy = h - (70 / 100) * (h - 6) - 3;
let d = `M${xs[0]} ${ys[0]}`;
for (let i = 1; i < pts.length; i++) {
const cx = (xs[i - 1] + xs[i]) / 2;
d += ` C${cx} ${ys[i - 1]},${cx} ${ys[i]},${xs[i]} ${ys[i]}`;
}
const uid = Math.random().toString(36).slice(2);
return `<defs>
<linearGradient id="g${uid}" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${fill}" stop-opacity=".4"/>
<stop offset="100%" stop-color="${fill}" stop-opacity=".02"/>
</linearGradient>
</defs>
<line x1="0" y1="${wy}" x2="${w}" y2="${wy}"
stroke="var(--warn)" stroke-width=".8" stroke-dasharray="3,3" opacity=".5"/>
<path d="${d} L${xs.at(-1)} ${h} L${xs[0]} ${h}Z" fill="url(#g${uid})"/>
<path d="${d}" fill="none" stroke="${stroke}" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="${xs.at(-1)}" cy="${ys.at(-1)}" r="2.5" fill="${stroke}"/>`;
}
function historyToCpuPts(history) {
return history.map(h => h.cpu_percent ?? 0);
}
function historyToMemPts(history) {
return history.map(h => {
if (!h.memory_total || h.memory_total === 0) return 0;
return (h.memory_used / h.memory_total) * 100;
});
}
function renderChart(svgEl, pts, color) {
if (!svgEl) return;
const cs = getComputedStyle(document.documentElement);
const c = cs.getPropertyValue(color).trim() || color;
svgEl.innerHTML = makeCurve(pts, c, c, 200, 52);
}
return { makeCurve, historyToCpuPts, historyToMemPts, renderChart };
})();
+235
View File
@@ -0,0 +1,235 @@
const Grid = (() => {
const _agents = new Map();
function statusClass(agent) {
if (agent.status === 'offline') return 't-off';
const m = _agents.get(agent.id)?.metrics;
if (!m) return '';
const cfg = App.serverConfig;
const errThreshold = cfg?.err_cpu ?? 85;
const warnThreshold = cfg?.warn_cpu ?? 70;
if ((m.cpu_percent ?? 0) >= errThreshold) return 't-err';
if ((m.cpu_percent ?? 0) >= warnThreshold || (m.hdd_used && m.hdd_total && (m.hdd_used / m.hdd_total * 100) >= (cfg?.warn_disk ?? 75))) return 't-warn';
return '';
}
function ledClass(status) {
return { online: 's-ok', warn: 's-warn', err: 's-err', offline: 's-off' }[status] ?? 's-off';
}
function fmt(bytes) {
if (!bytes) return '—';
if (bytes < 1024) return bytes + 'o';
if (bytes < 1024 ** 2) return (bytes / 1024).toFixed(1) + 'Ko';
if (bytes < 1024 ** 3) return (bytes / 1024 ** 2).toFixed(1) + 'Mo';
return (bytes / 1024 ** 3).toFixed(1) + 'Go';
}
function fmtPct(val) {
return val != null ? val.toFixed(0) + '%' : '—';
}
function gFill(pct) {
const cfg = App.serverConfig;
if (pct >= (cfg?.err_cpu ?? 85)) return 'e';
if (pct >= (cfg?.warn_cpu ?? 70)) return 'w';
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) {
const id = agent.id;
const sc = statusClass(agent);
const offline = agent.status === 'offline';
const cpu = metrics?.cpu_percent ?? null;
const memPct = (metrics?.memory_used && metrics?.memory_total)
? metrics.memory_used / metrics.memory_total * 100 : null;
const diskPct = (metrics?.hdd_used && metrics?.hdd_total)
? metrics.hdd_used / metrics.hdd_total * 100 : null;
const uptimeSec = metrics?.uptime;
let uptimeStr = '';
if (uptimeSec) {
const d = Math.floor(uptimeSec / 86400);
const h = Math.floor((uptimeSec % 86400) / 3600);
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=""
style="width:100%;height:100%;object-fit:cover;border-radius:7px"
onerror="this.style.display='none';this.nextSibling.style.display='flex'">
<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>`;
return `<div class="tile ${sc}" id="tile-${id}" onclick="Popups.showDetail('${esc(id)}')">
<div class="tile-head">
<div class="t-icon">${iconContent}</div>
<div class="t-names">
<div class="t-host">${esc(agent.hostname)}</div>
<div class="t-ip">${esc(agent.ip) || '—'}</div>
</div>
<div class="t-led ${ledClass(agent.status)}"></div>
</div>
<div class="tile-gauges">
${renderGaugeRow('microchip', 'CPU', 'CPU',
offline ? 0 : (cpu ?? 0),
offline ? '' : gFill(cpu ?? 0),
offline ? '—' : fmtPct(cpu))}
${renderGaugeRow('memory', 'RAM', 'MÉMOIRE',
offline ? 0 : (memPct ?? 0),
offline ? '' : gFill(memPct ?? 0),
offline ? '—' : (metrics?.memory_used && metrics?.memory_total ? fmt(metrics.memory_used) + '/' + fmt(metrics.memory_total) : '—'))}
${renderGaugeRow('hard-drive', 'Disque', 'DISQUE',
offline ? 0 : (diskPct ?? 0),
offline ? '' : (diskPct >= (App.serverConfig?.warn_disk ?? 75) ? 'w' : ''),
offline ? '—' : (metrics?.hdd_used && metrics?.hdd_total ? fmt(metrics.hdd_used) + '/' + fmt(metrics.hdd_total) : '—'),
smartIco)}
</div>
<div class="tile-foot">
<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-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>`;
}
function update(agentId, metrics) {
let entry = _agents.get(agentId);
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;
const el = document.getElementById('tile-' + agentId);
if (el) {
el.outerHTML = renderTile(entry.agent, metrics);
}
updateStats();
}
function refresh(agents) {
agents.forEach(a => {
if (!_agents.has(a.id)) {
_agents.set(a.id, { agent: a, metrics: a.last_metrics || null });
} else {
_agents.get(a.id).agent = a;
}
});
const grid = document.getElementById('agents-grid');
if (!grid) return;
grid.innerHTML = agents.map(a => renderTile(a, _agents.get(a.id)?.metrics)).join('');
updateStats();
}
function updateStats() {
let total = 0, ok = 0, warn = 0, err = 0;
_agents.forEach(({ agent }) => {
total++;
const sc = statusClass(agent);
if (agent.status === 'offline') {}
else if (sc === 't-err') err++;
else if (sc === 't-warn') warn++;
else ok++;
});
document.getElementById('stat-total').textContent = total;
document.getElementById('stat-ok').textContent = ok;
document.getElementById('stat-warn').textContent = warn;
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 updateStatus(agentId, status) {
const entry = _agents.get(agentId);
if (!entry) return;
entry.agent.status = status;
const el = document.getElementById('tile-' + agentId);
if (el) el.outerHTML = renderTile(entry.agent, entry.metrics);
updateStats();
}
return { refresh, update, updateStatus, removeAgent, rerenderAll, getAgent, fmt, fmtPct, smartState };
})();
+591
View File
@@ -0,0 +1,591 @@
const Popups = (() => {
let _currentAgentId = null;
let _agentCfgData = null;
let _resizeObs = null;
let _resizeTimer = null;
// ══ POPUP DÉTAIL ══
async function showDetail(agentId) {
_currentAgentId = agentId;
const entry = Grid.getAgent(agentId);
if (!entry) return;
const { agent, metrics } = entry;
document.getElementById('pop-host').textContent = agent.hostname;
document.getElementById('pop-ip').textContent = agent.ip || '—';
const led = document.getElementById('pop-led');
led.className = 'pop-led';
led.style.background = agent.status === 'online' ? 'var(--ok)' : 'var(--err)';
led.style.boxShadow = `0 0 8px ${agent.status === 'online' ? 'var(--ok)' : 'var(--err)'}`;
// Icône
const img = document.getElementById('pop-icon-img');
const fa = document.getElementById('pop-icon-fa');
img.src = API.iconUrl(agentId) + '?t=' + Date.now();
img.style.display = 'block';
img.onerror = () => { img.style.display = 'none'; fa.style.display = 'flex'; };
fa.style.display = 'none';
// Upload icône
document.getElementById('pop-icon-wrap').onclick = () => document.getElementById('icon-upload').click();
document.getElementById('icon-upload').onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const hint = document.getElementById('icon-hint');
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
const up = metrics?.uptime;
if (up) {
const d = Math.floor(up / 86400), h = Math.floor((up % 86400) / 3600);
document.getElementById('pop-uptime').innerHTML =
`<i class="fa-solid fa-clock" style="margin-right:4px"></i>En ligne depuis ${d}j ${h}h`;
}
// Corps du popup
const now = Math.floor(Date.now() / 1000);
let history = [];
try { history = await API.getAgentHistory(agentId, now - 1800, now); } catch {}
const cpuPts = Charts.historyToCpuPts(history);
const memPts = Charts.historyToMemPts(history);
let ramMin = null, ramMax = null;
for (const h of history) {
if (h.memory_used != null) {
if (ramMin === null || h.memory_used < ramMin) ramMin = h.memory_used;
if (ramMax === null || h.memory_used > ramMax) ramMax = h.memory_used;
}
}
const ramMinMax = ramMin !== null
? `<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 = [
metrics?.cpu_percent != null ? `<span class="proto-badge udp"><i class="fa-solid fa-arrow-up"></i>UDP</span>` : '',
].filter(Boolean).join('');
document.getElementById('pop-body').innerHTML = `
<div>
<div class="sec-title">MÉTRIQUES ACTUELLES</div>
<div class="kpi-grid">
<div class="kpi"><div class="kpi-lbl">CPU</div>
<div class="kpi-val c-ok">${(metrics?.cpu_percent ?? 0).toFixed(0)}<span class="u">%</span></div></div>
<div class="kpi"><div class="kpi-lbl">MÉMOIRE</div>
<div class="kpi-val">${Grid.fmt(metrics?.memory_used)}</div>
<div class="kpi-sub">/ ${Grid.fmt(metrics?.memory_total)}</div></div>
<div class="kpi"><div class="kpi-lbl">DISQUE</div>
<div class="kpi-val">${Grid.fmt(metrics?.hdd_used)}</div>
<div class="kpi-sub">/ ${Grid.fmt(metrics?.hdd_total)}</div></div>
<div class="kpi"><div class="kpi-lbl">UPTIME</div>
<div class="kpi-val" style="font-size:15px">${document.getElementById('pop-uptime').textContent.replace(/.*depuis /,'')}</div></div>
</div>
</div>
<div>
<div class="sec-title">HISTORIQUE — 30 MIN</div>
<div class="charts-grid">
<div class="chart-card">
<div class="chart-header">
<div class="chart-label" style="color:var(--accent)"><i class="fa-solid fa-microchip"></i>CPU</div>
<span class="chart-cur c-ok">${(metrics?.cpu_percent ?? 0).toFixed(0)}%</span>
</div>
<svg class="chart-svg" viewBox="0 0 200 52" preserveAspectRatio="none" id="det-cpu-chart"></svg>
<div class="chart-axis"><span>30min</span><span>15min</span><span>now</span></div>
</div>
<div class="chart-card">
<div class="chart-header">
<div class="chart-label" style="color:var(--blue)"><i class="fa-solid fa-memory"></i>RAM</div>
<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>
<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>
${ramMinMax}
</div>
</div>
</div>
<div>
<div class="sec-title">STOCKAGE</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div style="display:flex;align-items:center;gap:10px">
<div style="width:22px;text-align:center;font-size:13px;cursor:help" data-tip="Utilisé"><i class="fa-solid fa-hard-drive"></i></div>
<div style="flex:1;height:7px;border-radius:4px;background:var(--bg-1);overflow:hidden">
<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>
</div>
${smartBadges}
</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 class="sec-title">INFORMATIONS</div>
<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">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">DERNIER CONTACT</div><div class="meta-val">${new Date(agent.last_seen * 1000).toLocaleTimeString('fr-FR')}</div></div>
</div>
</div>`;
requestAnimationFrame(() => {
Charts.renderChart(document.getElementById('det-cpu-chart'), cpuPts, '--accent');
Charts.renderChart(document.getElementById('det-mem-chart'), memPts, '--blue');
});
// Resize → sauvegarder sur serveur
if (_resizeObs) _resizeObs.disconnect();
const pd = document.getElementById('popup-detail');
_resizeObs = new ResizeObserver(() => {
clearTimeout(_resizeTimer);
_resizeTimer = setTimeout(() => {
API.putServerConfig({
...App.serverConfig,
popup_detail_w: pd.offsetWidth,
popup_detail_h: pd.offsetHeight,
}).catch(() => {});
}, 600);
});
_resizeObs.observe(pd);
document.getElementById('overlay-detail').style.display = 'flex';
}
function hideDetail() {
document.getElementById('overlay-detail').style.display = 'none';
}
// ══ CONFIG AGENT ══
async function showAgentCfg() {
if (!_currentAgentId) return;
let cfg = {};
try { cfg = await API.getAgentConfig(_currentAgentId); } catch {}
_agentCfgData = cfg;
document.getElementById('agentcfg-sub').textContent =
`${_currentAgentId} · config récupérée`;
const metrics = ['cpu','memory','disk','smart','uptime','network','temperature'];
const icons = {
cpu:'fa-microchip',memory:'fa-memory',disk:'fa-hard-drive',
smart:'fa-shield-heart',uptime:'fa-clock',network:'fa-network-wired',
temperature:'fa-thermometer-half'
};
const mqttCfg = cfg.protocols?.mqtt ?? {};
document.getElementById('agentcfg-body').innerHTML = `
<div style="display:flex;flex-direction:column;gap:8px">
<div style="font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.08em;padding-bottom:6px;border-bottom:1px solid var(--border-1)">MÉTRIQUES PAR PROTOCOLE</div>
<div class="metrics-table">
<div class="metrics-header">
<span class="mh-label">MÉTRIQUE</span>
<span class="mh-proto udp"><i class="fa-solid fa-arrow-up"></i> UDP</span>
<span class="mh-proto mqtt"><i class="fa-solid fa-tower-broadcast" style="font-size:8px"></i> MQTT</span>
</div>
${metrics.map(m => {
const udpOn = cfg.metrics?.[m]?.udp ? 'udp-on' : '';
const mqttOn = cfg.metrics?.[m]?.mqtt ? 'mqtt-on' : '';
return `<div class="metric-row">
<div class="metric-cell">
<div class="metric-ico"><i class="fa-solid ${icons[m]}"></i></div>
<span class="metric-name">${m}</span>
</div>
<div class="metric-chk"><div class="cbox ${udpOn}" id="cbox-${m}-udp" onclick="Popups.toggleCbox(this,'${m}','udp')" data-tip="${m} via UDP"><i class="fa-solid fa-check"></i></div></div>
<div class="metric-chk"><div class="cbox ${mqttOn}" id="cbox-${m}-mqtt" onclick="Popups.toggleCbox(this,'${m}','mqtt')" data-tip="${m} via MQTT"><i class="fa-solid fa-check"></i></div></div>
</div>`;
}).join('')}
</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div style="font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.08em;padding-bottom:6px;border-bottom:1px solid var(--border-1)">PARAMÈTRES MQTT</div>
<div style="background:var(--bg-3);border-radius:8px;border:1px solid rgba(200,130,200,.2);padding:12px 14px;display:flex;flex-direction:column;gap:10px">
<div style="display:flex;align-items:center;gap:10px"><label style="font-size:11px;color:var(--ink-3);font-family:var(--font-terminal);width:90px">Broker</label>
<input id="mqtt-host" style="flex:1;background:var(--bg-1);border:1px solid var(--border-2);border-radius:6px;color:var(--ink-1);padding:6px 10px;font-size:12px;font-family:var(--font-mono)" value="${mqttCfg.host ?? '10.0.0.3'}"></div>
<div style="display:flex;align-items:center;gap:10px"><label style="font-size:11px;color:var(--ink-3);font-family:var(--font-terminal);width:90px">Port</label>
<input id="mqtt-port" type="number" style="width:90px;background:var(--bg-1);border:1px solid var(--border-2);border-radius:6px;color:var(--ink-1);padding:6px 10px;font-size:12px;font-family:var(--font-mono)" value="${mqttCfg.port ?? 1883}"></div>
<div style="display:flex;align-items:center;gap:10px"><label style="font-size:11px;color:var(--ink-3);font-family:var(--font-terminal);width:90px">Topic base</label>
<input id="mqtt-topic" style="flex:1;background:var(--bg-1);border:1px solid var(--border-2);border-radius:6px;color:var(--ink-1);padding:6px 10px;font-size:12px;font-family:var(--font-mono)" value="${mqttCfg.topic_base ?? 'nanometrics/agents'}"></div>
<div style="border-top:1px solid var(--border-1);padding-top:8px;display:flex;flex-direction:column;gap:5px">
${['auto_discovery:Auto-discovery (Home Assistant):fa-satellite-dish',
'birth_message:Birth message:fa-arrow-right-to-bracket',
'last_will:Last Will message:fa-skull'].map(s => {
const [key, label, icon] = s.split(':');
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:3px 0">
<label style="font-size:12px;color:var(--ink-2);display:flex;align-items:center;gap:7px;cursor:pointer">
<i class="fa-solid ${icon}" style="color:var(--purple);font-size:11px"></i>${label}
</label>
<label class="toggle">
<input type="checkbox" id="mqtt-${key}" ${mqttCfg[key] !== false ? 'checked' : ''}>
<span class="toggle-slider"></span>
</label>
</div>`;
}).join('')}
</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div style="font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.08em;padding-bottom:6px;border-bottom:1px solid var(--border-1)">
COMMANDES DISTANTES <span style="color:var(--ink-4);font-size:8px;margin-left:6px">— BIENTÔT</span>
</div>
<div style="display:grid;grid-template-columns:repeat(3,1fr);gap:6px">
${[['fa-rotate-right','reboot'],['fa-power-off','shutdown'],['fa-display','screen off'],
['fa-arrow-up-from-bracket','update'],['fa-arrow-up-right-dots','upgrade'],['fa-terminal','shell cmd']].map(
([icon, label]) => `<div style="display:flex;flex-direction:column;align-items:center;gap:4px;padding:10px 8px;border-radius:8px;background:var(--bg-3);border:1px solid var(--border-1);cursor:not-allowed;opacity:.4">
<i class="fa-solid ${icon}" style="font-size:16px;color:var(--ink-3)"></i>
<span style="font-size:10px;color:var(--ink-4);font-family:var(--font-terminal)">${label}</span>
<span style="font-size:8px;color:var(--ink-4);font-family:var(--font-terminal)">bientôt</span>
</div>`).join('')}
</div>
</div>`;
document.getElementById('overlay-agentcfg').style.display = 'flex';
}
function toggleCbox(el, metric, proto) {
const isOn = el.classList.contains(proto + '-on');
el.classList.toggle(proto + '-on', !isOn);
el.style.color = isOn ? 'transparent' : '';
if (!_agentCfgData.metrics) _agentCfgData.metrics = {};
if (!_agentCfgData.metrics[metric]) _agentCfgData.metrics[metric] = {};
_agentCfgData.metrics[metric][proto] = !isOn;
}
async function sendAgentConfig() {
if (!_currentAgentId || !_agentCfgData) return;
if (!_agentCfgData.protocols) _agentCfgData.protocols = {};
_agentCfgData.protocols.mqtt = {
..._agentCfgData.protocols.mqtt,
host: document.getElementById('mqtt-host')?.value ?? '10.0.0.3',
port: parseInt(document.getElementById('mqtt-port')?.value ?? '1883'),
topic_base: document.getElementById('mqtt-topic')?.value ?? 'nanometrics/agents',
auto_discovery: document.getElementById('mqtt-auto_discovery')?.checked ?? true,
birth_message: document.getElementById('mqtt-birth_message')?.checked ?? true,
last_will: document.getElementById('mqtt-last_will')?.checked ?? true,
};
try {
await API.putAgentConfig(_currentAgentId, _agentCfgData);
document.getElementById('overlay-agentcfg').style.display = 'none';
} catch (e) {
alert('Erreur lors de l\'envoi : ' + e.message);
}
}
// ══ CONFIG SERVEUR ══
async function showSrvCfg() {
const cfg = App.serverConfig ?? {};
document.getElementById('btn-srvcfg').classList.add('active-btn');
document.getElementById('srvcfg-body').innerHTML = `
<div style="display:flex;flex-direction:column;gap:8px">
<div class="scfg-sec-title">AFFICHAGE DES TUILES</div>
<div class="scfg-row"><label>Largeur min.</label>
<input type="range" class="scfg-slider" min="160" max="420" value="${cfg.tile_min_width ?? 220}"
oninput="this.nextElementSibling.textContent=this.value+'px'" id="s-tile-w">
<span class="scfg-val">${cfg.tile_min_width ?? 220}px</span></div>
<div class="scfg-row"><label>Taille du texte</label>
<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">
<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 style="display:flex;flex-direction:column;gap:8px">
<div class="scfg-sec-title">SEUILS D'ALERTE</div>
<div class="scfg-row"><label>Warning CPU/RAM</label>
<input type="range" class="scfg-slider" min="50" max="95" value="${cfg.warn_cpu ?? 70}"
oninput="this.nextElementSibling.textContent=this.value+'%'" id="s-warn-cpu">
<span class="scfg-val">${cfg.warn_cpu ?? 70}%</span></div>
<div class="scfg-row"><label>Erreur CPU/RAM</label>
<input type="range" class="scfg-slider" min="60" max="100" value="${cfg.err_cpu ?? 85}"
oninput="this.nextElementSibling.textContent=this.value+'%'" id="s-err-cpu">
<span class="scfg-val">${cfg.err_cpu ?? 85}%</span></div>
<div class="scfg-row"><label>Warning Disque</label>
<input type="range" class="scfg-slider" min="50" max="95" value="${cfg.warn_disk ?? 75}"
oninput="this.nextElementSibling.textContent=this.value+'%'" id="s-warn-disk">
<span class="scfg-val">${cfg.warn_disk ?? 75}%</span></div>
</div>
<div style="display:flex;flex-direction:column;gap:8px">
<div class="scfg-sec-title">DONNÉES & RÉTENTION</div>
<div class="scfg-row"><label>Historique</label>
<select class="scfg-select" id="s-retention">
${[7,30,90,365].map(d => `<option value="${d}" ${(cfg.retention_days??30)==d?'selected':''}>${d} jours</option>`).join('')}
</select></div>
<div class="scfg-row"><label>Courbes (durée)</label>
<select class="scfg-select" id="s-chart-dur">
${[[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('')}
</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>`;
document.getElementById('overlay-srvcfg').style.display = 'flex';
}
function hideSrvCfg() {
document.getElementById('overlay-srvcfg').style.display = 'none';
document.getElementById('btn-srvcfg').classList.remove('active-btn');
}
async function saveSrvCfg() {
const cfg = {
...App.serverConfig,
tile_min_width: parseInt(document.getElementById('s-tile-w')?.value ?? 220),
font_size: parseInt(document.getElementById('s-font')?.value ?? 13),
warn_cpu: parseInt(document.getElementById('s-warn-cpu')?.value ?? 70),
err_cpu: parseInt(document.getElementById('s-err-cpu')?.value ?? 85),
warn_disk: parseInt(document.getElementById('s-warn-disk')?.value ?? 75),
retention_days: parseInt(document.getElementById('s-retention')?.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);
App.serverConfig = cfg;
document.documentElement.style.setProperty('--tile-min', cfg.tile_min_width + 'px');
document.body.style.fontSize = cfg.font_size + 'px';
if (cfg.gauge_type !== prevGaugeType) Grid.rerenderAll();
hideSrvCfg();
}
// ══ POPUP SMART ══
function showSmart(agentId, diskIdx = 0) {
const smartList = Grid.getAgent(agentId)?.metrics?.smart;
if (!smartList?.length) return;
const m = smartList[diskIdx] ?? smartList[0];
const state = Grid.smartState(m);
document.getElementById('smart-sub').textContent = m.device ? `${agentId}${m.device}` : agentId;
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 = `
<div class="smart-verdict" style="background:${si.bg};border-color:${si.border}">
<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:${si.color}">${si.title}</div>
<div style="font-size:12px;color:var(--ink-3);margin-top:3px">${si.desc}</div>
</div>
</div>
<div>
<div class="sec-title">POINTS DE CONTRÔLE</div>
<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)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<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-size:10px;font-family:var(--font-terminal);font-weight:700;padding:1px 7px;border-radius:999px;background:${tempBg};color:${tempColor}">${tempLabel}</span>
</div>
<div class="si-val">${m.temperature}<span class="u">°C</span></div>
<div class="si-desc">Normale entre 2050°C. Au-delà de 60°C le disque risque de s'abîmer.</div>
</div>` : ''}
${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">
<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>
</div>
<div class="si-val">${m.reallocated_sectors}<span class="u"> sect.</span></div>
<div class="si-desc">${secDesc}</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)">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
<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">Durée de fonctionnement</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 d'utilisation. Un disque dur dure en moyenne 3 à 5 ans (25 00040 000 h).</div>
</div>` : ''}
${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">
<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 restante</span>
</div>
<div class="si-val">${m.wear_level}<span class="u">%</span></div>
<div class="si-desc">${wearDesc}</div>
</div>` : ''}
</div>
</div>`;
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 {
showDetail, hideDetail,
showAgentCfg, sendAgentConfig, toggleCbox,
showSrvCfg, hideSrvCfg, saveSrvCfg, confirmDeleteAgent, doDeleteAgent,
showSmart, _copyInstallCmd,
};
})();
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+19
View File
@@ -0,0 +1,19 @@
/*!
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2023 Fonticons, Inc.
*/
:root, :host {
--fa-style-family-classic: 'Font Awesome 6 Free';
--fa-font-regular: normal 400 1em/1 'Font Awesome 6 Free'; }
@font-face {
font-family: 'Font Awesome 6 Free';
font-style: normal;
font-weight: 400;
font-display: block;
src: url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.ttf") format("truetype"); }
.far,
.fa-regular {
font-weight: 400; }
+6
View File
@@ -0,0 +1,6 @@
/*!
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2023 Fonticons, Inc.
*/
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}
+19
View File
@@ -0,0 +1,19 @@
/*!
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2023 Fonticons, Inc.
*/
:root, :host {
--fa-style-family-classic: 'Font Awesome 6 Free';
--fa-font-solid: normal 900 1em/1 'Font Awesome 6 Free'; }
@font-face {
font-family: 'Font Awesome 6 Free';
font-style: normal;
font-weight: 900;
font-display: block;
src: url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.ttf") format("truetype"); }
.fas,
.fa-solid {
font-weight: 900; }
+6
View File
@@ -0,0 +1,6 @@
/*!
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2023 Fonticons, Inc.
*/
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}
+640
View File
@@ -0,0 +1,640 @@
/*!
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2023 Fonticons, Inc.
*/
:root, :host {
--fa-font-solid: normal 900 1em/1 'Font Awesome 6 Solid';
--fa-font-regular: normal 400 1em/1 'Font Awesome 6 Regular';
--fa-font-light: normal 300 1em/1 'Font Awesome 6 Light';
--fa-font-thin: normal 100 1em/1 'Font Awesome 6 Thin';
--fa-font-duotone: normal 900 1em/1 'Font Awesome 6 Duotone';
--fa-font-sharp-solid: normal 900 1em/1 'Font Awesome 6 Sharp';
--fa-font-sharp-regular: normal 400 1em/1 'Font Awesome 6 Sharp';
--fa-font-sharp-light: normal 300 1em/1 'Font Awesome 6 Sharp';
--fa-font-sharp-thin: normal 100 1em/1 'Font Awesome 6 Sharp';
--fa-font-brands: normal 400 1em/1 'Font Awesome 6 Brands'; }
svg:not(:root).svg-inline--fa, svg:not(:host).svg-inline--fa {
overflow: visible;
box-sizing: content-box; }
.svg-inline--fa {
display: var(--fa-display, inline-block);
height: 1em;
overflow: visible;
vertical-align: -.125em; }
.svg-inline--fa.fa-2xs {
vertical-align: 0.1em; }
.svg-inline--fa.fa-xs {
vertical-align: 0em; }
.svg-inline--fa.fa-sm {
vertical-align: -0.07143em; }
.svg-inline--fa.fa-lg {
vertical-align: -0.2em; }
.svg-inline--fa.fa-xl {
vertical-align: -0.25em; }
.svg-inline--fa.fa-2xl {
vertical-align: -0.3125em; }
.svg-inline--fa.fa-pull-left {
margin-right: var(--fa-pull-margin, 0.3em);
width: auto; }
.svg-inline--fa.fa-pull-right {
margin-left: var(--fa-pull-margin, 0.3em);
width: auto; }
.svg-inline--fa.fa-li {
width: var(--fa-li-width, 2em);
top: 0.25em; }
.svg-inline--fa.fa-fw {
width: var(--fa-fw-width, 1.25em); }
.fa-layers svg.svg-inline--fa {
bottom: 0;
left: 0;
margin: auto;
position: absolute;
right: 0;
top: 0; }
.fa-layers-text, .fa-layers-counter {
display: inline-block;
position: absolute;
text-align: center; }
.fa-layers {
display: inline-block;
height: 1em;
position: relative;
text-align: center;
vertical-align: -.125em;
width: 1em; }
.fa-layers svg.svg-inline--fa {
-webkit-transform-origin: center center;
transform-origin: center center; }
.fa-layers-text {
left: 50%;
top: 50%;
-webkit-transform: translate(-50%, -50%);
transform: translate(-50%, -50%);
-webkit-transform-origin: center center;
transform-origin: center center; }
.fa-layers-counter {
background-color: var(--fa-counter-background-color, #ff253a);
border-radius: var(--fa-counter-border-radius, 1em);
box-sizing: border-box;
color: var(--fa-inverse, #fff);
line-height: var(--fa-counter-line-height, 1);
max-width: var(--fa-counter-max-width, 5em);
min-width: var(--fa-counter-min-width, 1.5em);
overflow: hidden;
padding: var(--fa-counter-padding, 0.25em 0.5em);
right: var(--fa-right, 0);
text-overflow: ellipsis;
top: var(--fa-top, 0);
-webkit-transform: scale(var(--fa-counter-scale, 0.25));
transform: scale(var(--fa-counter-scale, 0.25));
-webkit-transform-origin: top right;
transform-origin: top right; }
.fa-layers-bottom-right {
bottom: var(--fa-bottom, 0);
right: var(--fa-right, 0);
top: auto;
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
transform: scale(var(--fa-layers-scale, 0.25));
-webkit-transform-origin: bottom right;
transform-origin: bottom right; }
.fa-layers-bottom-left {
bottom: var(--fa-bottom, 0);
left: var(--fa-left, 0);
right: auto;
top: auto;
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
transform: scale(var(--fa-layers-scale, 0.25));
-webkit-transform-origin: bottom left;
transform-origin: bottom left; }
.fa-layers-top-right {
top: var(--fa-top, 0);
right: var(--fa-right, 0);
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
transform: scale(var(--fa-layers-scale, 0.25));
-webkit-transform-origin: top right;
transform-origin: top right; }
.fa-layers-top-left {
left: var(--fa-left, 0);
right: auto;
top: var(--fa-top, 0);
-webkit-transform: scale(var(--fa-layers-scale, 0.25));
transform: scale(var(--fa-layers-scale, 0.25));
-webkit-transform-origin: top left;
transform-origin: top left; }
.fa-1x {
font-size: 1em; }
.fa-2x {
font-size: 2em; }
.fa-3x {
font-size: 3em; }
.fa-4x {
font-size: 4em; }
.fa-5x {
font-size: 5em; }
.fa-6x {
font-size: 6em; }
.fa-7x {
font-size: 7em; }
.fa-8x {
font-size: 8em; }
.fa-9x {
font-size: 9em; }
.fa-10x {
font-size: 10em; }
.fa-2xs {
font-size: 0.625em;
line-height: 0.1em;
vertical-align: 0.225em; }
.fa-xs {
font-size: 0.75em;
line-height: 0.08333em;
vertical-align: 0.125em; }
.fa-sm {
font-size: 0.875em;
line-height: 0.07143em;
vertical-align: 0.05357em; }
.fa-lg {
font-size: 1.25em;
line-height: 0.05em;
vertical-align: -0.075em; }
.fa-xl {
font-size: 1.5em;
line-height: 0.04167em;
vertical-align: -0.125em; }
.fa-2xl {
font-size: 2em;
line-height: 0.03125em;
vertical-align: -0.1875em; }
.fa-fw {
text-align: center;
width: 1.25em; }
.fa-ul {
list-style-type: none;
margin-left: var(--fa-li-margin, 2.5em);
padding-left: 0; }
.fa-ul > li {
position: relative; }
.fa-li {
left: calc(var(--fa-li-width, 2em) * -1);
position: absolute;
text-align: center;
width: var(--fa-li-width, 2em);
line-height: inherit; }
.fa-border {
border-color: var(--fa-border-color, #eee);
border-radius: var(--fa-border-radius, 0.1em);
border-style: var(--fa-border-style, solid);
border-width: var(--fa-border-width, 0.08em);
padding: var(--fa-border-padding, 0.2em 0.25em 0.15em); }
.fa-pull-left {
float: left;
margin-right: var(--fa-pull-margin, 0.3em); }
.fa-pull-right {
float: right;
margin-left: var(--fa-pull-margin, 0.3em); }
.fa-beat {
-webkit-animation-name: fa-beat;
animation-name: fa-beat;
-webkit-animation-delay: var(--fa-animation-delay, 0s);
animation-delay: var(--fa-animation-delay, 0s);
-webkit-animation-direction: var(--fa-animation-direction, normal);
animation-direction: var(--fa-animation-direction, normal);
-webkit-animation-duration: var(--fa-animation-duration, 1s);
animation-duration: var(--fa-animation-duration, 1s);
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
-webkit-animation-timing-function: var(--fa-animation-timing, ease-in-out);
animation-timing-function: var(--fa-animation-timing, ease-in-out); }
.fa-bounce {
-webkit-animation-name: fa-bounce;
animation-name: fa-bounce;
-webkit-animation-delay: var(--fa-animation-delay, 0s);
animation-delay: var(--fa-animation-delay, 0s);
-webkit-animation-direction: var(--fa-animation-direction, normal);
animation-direction: var(--fa-animation-direction, normal);
-webkit-animation-duration: var(--fa-animation-duration, 1s);
animation-duration: var(--fa-animation-duration, 1s);
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
-webkit-animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1));
animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.28, 0.84, 0.42, 1)); }
.fa-fade {
-webkit-animation-name: fa-fade;
animation-name: fa-fade;
-webkit-animation-delay: var(--fa-animation-delay, 0s);
animation-delay: var(--fa-animation-delay, 0s);
-webkit-animation-direction: var(--fa-animation-direction, normal);
animation-direction: var(--fa-animation-direction, normal);
-webkit-animation-duration: var(--fa-animation-duration, 1s);
animation-duration: var(--fa-animation-duration, 1s);
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
-webkit-animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1));
animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1)); }
.fa-beat-fade {
-webkit-animation-name: fa-beat-fade;
animation-name: fa-beat-fade;
-webkit-animation-delay: var(--fa-animation-delay, 0s);
animation-delay: var(--fa-animation-delay, 0s);
-webkit-animation-direction: var(--fa-animation-direction, normal);
animation-direction: var(--fa-animation-direction, normal);
-webkit-animation-duration: var(--fa-animation-duration, 1s);
animation-duration: var(--fa-animation-duration, 1s);
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
-webkit-animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1));
animation-timing-function: var(--fa-animation-timing, cubic-bezier(0.4, 0, 0.6, 1)); }
.fa-flip {
-webkit-animation-name: fa-flip;
animation-name: fa-flip;
-webkit-animation-delay: var(--fa-animation-delay, 0s);
animation-delay: var(--fa-animation-delay, 0s);
-webkit-animation-direction: var(--fa-animation-direction, normal);
animation-direction: var(--fa-animation-direction, normal);
-webkit-animation-duration: var(--fa-animation-duration, 1s);
animation-duration: var(--fa-animation-duration, 1s);
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
-webkit-animation-timing-function: var(--fa-animation-timing, ease-in-out);
animation-timing-function: var(--fa-animation-timing, ease-in-out); }
.fa-shake {
-webkit-animation-name: fa-shake;
animation-name: fa-shake;
-webkit-animation-delay: var(--fa-animation-delay, 0s);
animation-delay: var(--fa-animation-delay, 0s);
-webkit-animation-direction: var(--fa-animation-direction, normal);
animation-direction: var(--fa-animation-direction, normal);
-webkit-animation-duration: var(--fa-animation-duration, 1s);
animation-duration: var(--fa-animation-duration, 1s);
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
-webkit-animation-timing-function: var(--fa-animation-timing, linear);
animation-timing-function: var(--fa-animation-timing, linear); }
.fa-spin {
-webkit-animation-name: fa-spin;
animation-name: fa-spin;
-webkit-animation-delay: var(--fa-animation-delay, 0s);
animation-delay: var(--fa-animation-delay, 0s);
-webkit-animation-direction: var(--fa-animation-direction, normal);
animation-direction: var(--fa-animation-direction, normal);
-webkit-animation-duration: var(--fa-animation-duration, 2s);
animation-duration: var(--fa-animation-duration, 2s);
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
-webkit-animation-timing-function: var(--fa-animation-timing, linear);
animation-timing-function: var(--fa-animation-timing, linear); }
.fa-spin-reverse {
--fa-animation-direction: reverse; }
.fa-pulse,
.fa-spin-pulse {
-webkit-animation-name: fa-spin;
animation-name: fa-spin;
-webkit-animation-direction: var(--fa-animation-direction, normal);
animation-direction: var(--fa-animation-direction, normal);
-webkit-animation-duration: var(--fa-animation-duration, 1s);
animation-duration: var(--fa-animation-duration, 1s);
-webkit-animation-iteration-count: var(--fa-animation-iteration-count, infinite);
animation-iteration-count: var(--fa-animation-iteration-count, infinite);
-webkit-animation-timing-function: var(--fa-animation-timing, steps(8));
animation-timing-function: var(--fa-animation-timing, steps(8)); }
@media (prefers-reduced-motion: reduce) {
.fa-beat,
.fa-bounce,
.fa-fade,
.fa-beat-fade,
.fa-flip,
.fa-pulse,
.fa-shake,
.fa-spin,
.fa-spin-pulse {
-webkit-animation-delay: -1ms;
animation-delay: -1ms;
-webkit-animation-duration: 1ms;
animation-duration: 1ms;
-webkit-animation-iteration-count: 1;
animation-iteration-count: 1;
-webkit-transition-delay: 0s;
transition-delay: 0s;
-webkit-transition-duration: 0s;
transition-duration: 0s; } }
@-webkit-keyframes fa-beat {
0%, 90% {
-webkit-transform: scale(1);
transform: scale(1); }
45% {
-webkit-transform: scale(var(--fa-beat-scale, 1.25));
transform: scale(var(--fa-beat-scale, 1.25)); } }
@keyframes fa-beat {
0%, 90% {
-webkit-transform: scale(1);
transform: scale(1); }
45% {
-webkit-transform: scale(var(--fa-beat-scale, 1.25));
transform: scale(var(--fa-beat-scale, 1.25)); } }
@-webkit-keyframes fa-bounce {
0% {
-webkit-transform: scale(1, 1) translateY(0);
transform: scale(1, 1) translateY(0); }
10% {
-webkit-transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0);
transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0); }
30% {
-webkit-transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em));
transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em)); }
50% {
-webkit-transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0);
transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0); }
57% {
-webkit-transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em));
transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em)); }
64% {
-webkit-transform: scale(1, 1) translateY(0);
transform: scale(1, 1) translateY(0); }
100% {
-webkit-transform: scale(1, 1) translateY(0);
transform: scale(1, 1) translateY(0); } }
@keyframes fa-bounce {
0% {
-webkit-transform: scale(1, 1) translateY(0);
transform: scale(1, 1) translateY(0); }
10% {
-webkit-transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0);
transform: scale(var(--fa-bounce-start-scale-x, 1.1), var(--fa-bounce-start-scale-y, 0.9)) translateY(0); }
30% {
-webkit-transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em));
transform: scale(var(--fa-bounce-jump-scale-x, 0.9), var(--fa-bounce-jump-scale-y, 1.1)) translateY(var(--fa-bounce-height, -0.5em)); }
50% {
-webkit-transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0);
transform: scale(var(--fa-bounce-land-scale-x, 1.05), var(--fa-bounce-land-scale-y, 0.95)) translateY(0); }
57% {
-webkit-transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em));
transform: scale(1, 1) translateY(var(--fa-bounce-rebound, -0.125em)); }
64% {
-webkit-transform: scale(1, 1) translateY(0);
transform: scale(1, 1) translateY(0); }
100% {
-webkit-transform: scale(1, 1) translateY(0);
transform: scale(1, 1) translateY(0); } }
@-webkit-keyframes fa-fade {
50% {
opacity: var(--fa-fade-opacity, 0.4); } }
@keyframes fa-fade {
50% {
opacity: var(--fa-fade-opacity, 0.4); } }
@-webkit-keyframes fa-beat-fade {
0%, 100% {
opacity: var(--fa-beat-fade-opacity, 0.4);
-webkit-transform: scale(1);
transform: scale(1); }
50% {
opacity: 1;
-webkit-transform: scale(var(--fa-beat-fade-scale, 1.125));
transform: scale(var(--fa-beat-fade-scale, 1.125)); } }
@keyframes fa-beat-fade {
0%, 100% {
opacity: var(--fa-beat-fade-opacity, 0.4);
-webkit-transform: scale(1);
transform: scale(1); }
50% {
opacity: 1;
-webkit-transform: scale(var(--fa-beat-fade-scale, 1.125));
transform: scale(var(--fa-beat-fade-scale, 1.125)); } }
@-webkit-keyframes fa-flip {
50% {
-webkit-transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg));
transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg)); } }
@keyframes fa-flip {
50% {
-webkit-transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg));
transform: rotate3d(var(--fa-flip-x, 0), var(--fa-flip-y, 1), var(--fa-flip-z, 0), var(--fa-flip-angle, -180deg)); } }
@-webkit-keyframes fa-shake {
0% {
-webkit-transform: rotate(-15deg);
transform: rotate(-15deg); }
4% {
-webkit-transform: rotate(15deg);
transform: rotate(15deg); }
8%, 24% {
-webkit-transform: rotate(-18deg);
transform: rotate(-18deg); }
12%, 28% {
-webkit-transform: rotate(18deg);
transform: rotate(18deg); }
16% {
-webkit-transform: rotate(-22deg);
transform: rotate(-22deg); }
20% {
-webkit-transform: rotate(22deg);
transform: rotate(22deg); }
32% {
-webkit-transform: rotate(-12deg);
transform: rotate(-12deg); }
36% {
-webkit-transform: rotate(12deg);
transform: rotate(12deg); }
40%, 100% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg); } }
@keyframes fa-shake {
0% {
-webkit-transform: rotate(-15deg);
transform: rotate(-15deg); }
4% {
-webkit-transform: rotate(15deg);
transform: rotate(15deg); }
8%, 24% {
-webkit-transform: rotate(-18deg);
transform: rotate(-18deg); }
12%, 28% {
-webkit-transform: rotate(18deg);
transform: rotate(18deg); }
16% {
-webkit-transform: rotate(-22deg);
transform: rotate(-22deg); }
20% {
-webkit-transform: rotate(22deg);
transform: rotate(22deg); }
32% {
-webkit-transform: rotate(-12deg);
transform: rotate(-12deg); }
36% {
-webkit-transform: rotate(12deg);
transform: rotate(12deg); }
40%, 100% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg); } }
@-webkit-keyframes fa-spin {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg); }
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg); } }
@keyframes fa-spin {
0% {
-webkit-transform: rotate(0deg);
transform: rotate(0deg); }
100% {
-webkit-transform: rotate(360deg);
transform: rotate(360deg); } }
.fa-rotate-90 {
-webkit-transform: rotate(90deg);
transform: rotate(90deg); }
.fa-rotate-180 {
-webkit-transform: rotate(180deg);
transform: rotate(180deg); }
.fa-rotate-270 {
-webkit-transform: rotate(270deg);
transform: rotate(270deg); }
.fa-flip-horizontal {
-webkit-transform: scale(-1, 1);
transform: scale(-1, 1); }
.fa-flip-vertical {
-webkit-transform: scale(1, -1);
transform: scale(1, -1); }
.fa-flip-both,
.fa-flip-horizontal.fa-flip-vertical {
-webkit-transform: scale(-1, -1);
transform: scale(-1, -1); }
.fa-rotate-by {
-webkit-transform: rotate(var(--fa-rotate-angle, none));
transform: rotate(var(--fa-rotate-angle, none)); }
.fa-stack {
display: inline-block;
vertical-align: middle;
height: 2em;
position: relative;
width: 2.5em; }
.fa-stack-1x,
.fa-stack-2x {
bottom: 0;
left: 0;
margin: auto;
position: absolute;
right: 0;
top: 0;
z-index: var(--fa-stack-z-index, auto); }
.svg-inline--fa.fa-stack-1x {
height: 1em;
width: 1.25em; }
.svg-inline--fa.fa-stack-2x {
height: 2em;
width: 2.5em; }
.fa-inverse {
color: var(--fa-inverse, #fff); }
.sr-only,
.fa-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0; }
.sr-only-focusable:not(:focus),
.fa-sr-only-focusable:not(:focus) {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0; }
.svg-inline--fa .fa-primary {
fill: var(--fa-primary-color, currentColor);
opacity: var(--fa-primary-opacity, 1); }
.svg-inline--fa .fa-secondary {
fill: var(--fa-secondary-color, currentColor);
opacity: var(--fa-secondary-opacity, 0.4); }
.svg-inline--fa.fa-swap-opacity .fa-primary {
opacity: var(--fa-secondary-opacity, 0.4); }
.svg-inline--fa.fa-swap-opacity .fa-secondary {
opacity: var(--fa-primary-opacity, 1); }
.svg-inline--fa mask .fa-primary,
.svg-inline--fa mask .fa-secondary {
fill: black; }
.fad.fa-inverse,
.fa-duotone.fa-inverse {
color: var(--fa-inverse, #fff); }
File diff suppressed because one or more lines are too long
+26
View File
@@ -0,0 +1,26 @@
/*!
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2023 Fonticons, Inc.
*/
@font-face {
font-family: 'FontAwesome';
font-display: block;
src: url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.ttf") format("truetype"); }
@font-face {
font-family: 'FontAwesome';
font-display: block;
src: url("../webfonts/fa-brands-400.woff2") format("woff2"), url("../webfonts/fa-brands-400.ttf") format("truetype"); }
@font-face {
font-family: 'FontAwesome';
font-display: block;
src: url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.ttf") format("truetype");
unicode-range: U+F003,U+F006,U+F014,U+F016-F017,U+F01A-F01B,U+F01D,U+F022,U+F03E,U+F044,U+F046,U+F05C-F05D,U+F06E,U+F070,U+F087-F088,U+F08A,U+F094,U+F096-F097,U+F09D,U+F0A0,U+F0A2,U+F0A4-F0A7,U+F0C5,U+F0C7,U+F0E5-F0E6,U+F0EB,U+F0F6-F0F8,U+F10C,U+F114-F115,U+F118-F11A,U+F11C-F11D,U+F133,U+F147,U+F14E,U+F150-F152,U+F185-F186,U+F18E,U+F190-F192,U+F196,U+F1C1-F1C9,U+F1D9,U+F1DB,U+F1E3,U+F1EA,U+F1F7,U+F1F9,U+F20A,U+F247-F248,U+F24A,U+F24D,U+F255-F25B,U+F25D,U+F271-F274,U+F278,U+F27B,U+F28C,U+F28E,U+F29C,U+F2B5,U+F2B7,U+F2BA,U+F2BC,U+F2BE,U+F2C0-F2C1,U+F2C3,U+F2D0,U+F2D2,U+F2D4,U+F2DC; }
@font-face {
font-family: 'FontAwesome';
font-display: block;
src: url("../webfonts/fa-v4compatibility.woff2") format("woff2"), url("../webfonts/fa-v4compatibility.ttf") format("truetype");
unicode-range: U+F041,U+F047,U+F065-F066,U+F07D-F07E,U+F080,U+F08B,U+F08E,U+F090,U+F09A,U+F0AC,U+F0AE,U+F0B2,U+F0D0,U+F0D6,U+F0E4,U+F0EC,U+F10A-F10B,U+F123,U+F13E,U+F148-F149,U+F14C,U+F156,U+F15E,U+F160-F161,U+F163,U+F175-F178,U+F195,U+F1F8,U+F219,U+F27A; }
+6
View File
@@ -0,0 +1,6 @@
/*!
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2023 Fonticons, Inc.
*/
@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype");unicode-range:u+f003,u+f006,u+f014,u+f016-f017,u+f01a-f01b,u+f01d,u+f022,u+f03e,u+f044,u+f046,u+f05c-f05d,u+f06e,u+f070,u+f087-f088,u+f08a,u+f094,u+f096-f097,u+f09d,u+f0a0,u+f0a2,u+f0a4-f0a7,u+f0c5,u+f0c7,u+f0e5-f0e6,u+f0eb,u+f0f6-f0f8,u+f10c,u+f114-f115,u+f118-f11a,u+f11c-f11d,u+f133,u+f147,u+f14e,u+f150-f152,u+f185-f186,u+f18e,u+f190-f192,u+f196,u+f1c1-f1c9,u+f1d9,u+f1db,u+f1e3,u+f1ea,u+f1f7,u+f1f9,u+f20a,u+f247-f248,u+f24a,u+f24d,u+f255-f25b,u+f25d,u+f271-f274,u+f278,u+f27b,u+f28c,u+f28e,u+f29c,u+f2b5,u+f2b7,u+f2ba,u+f2bc,u+f2be,u+f2c0-f2c1,u+f2c3,u+f2d0,u+f2d2,u+f2d4,u+f2dc}@font-face{font-family:"FontAwesome";font-display:block;src:url(../webfonts/fa-v4compatibility.woff2) format("woff2"),url(../webfonts/fa-v4compatibility.ttf) format("truetype");unicode-range:u+f041,u+f047,u+f065-f066,u+f07d-f07e,u+f080,u+f08b,u+f08e,u+f090,u+f09a,u+f0ac,u+f0ae,u+f0b2,u+f0d0,u+f0d6,u+f0e4,u+f0ec,u+f10a-f10b,u+f123,u+f13e,u+f148-f149,u+f14c,u+f156,u+f15e,u+f160-f161,u+f163,u+f175-f178,u+f195,u+f1f8,u+f219,u+f27a}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+22
View File
@@ -0,0 +1,22 @@
/*!
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2023 Fonticons, Inc.
*/
@font-face {
font-family: 'Font Awesome 5 Brands';
font-display: block;
font-weight: 400;
src: url("../webfonts/fa-brands-400.woff2") format("woff2"), url("../webfonts/fa-brands-400.ttf") format("truetype"); }
@font-face {
font-family: 'Font Awesome 5 Free';
font-display: block;
font-weight: 900;
src: url("../webfonts/fa-solid-900.woff2") format("woff2"), url("../webfonts/fa-solid-900.ttf") format("truetype"); }
@font-face {
font-family: 'Font Awesome 5 Free';
font-display: block;
font-weight: 400;
src: url("../webfonts/fa-regular-400.woff2") format("woff2"), url("../webfonts/fa-regular-400.ttf") format("truetype"); }
+6
View File
@@ -0,0 +1,6 @@
/*!
* Font Awesome Free 6.5.1 by @fontawesome - https://fontawesome.com
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
* Copyright 2023 Fonticons, Inc.
*/
@font-face{font-family:"Font Awesome 5 Brands";font-display:block;font-weight:400;src:url(../webfonts/fa-brands-400.woff2) format("woff2"),url(../webfonts/fa-brands-400.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:900;src:url(../webfonts/fa-solid-900.woff2) format("woff2"),url(../webfonts/fa-solid-900.ttf) format("truetype")}@font-face{font-family:"Font Awesome 5 Free";font-display:block;font-weight:400;src:url(../webfonts/fa-regular-400.woff2) format("woff2"),url(../webfonts/fa-regular-400.ttf) format("truetype")}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -e
BINARY="$(dirname "$0")/../agent/target/release/nanometrics-agent"
SERVICE="$(dirname "$0")/nanometrics-agent.service"
CONFIG="$(dirname "$0")/../agent/config.toml"
# Vérifications
if [ ! -f "$BINARY" ]; then
echo "ERREUR : binaire introuvable : $BINARY"
echo "Compilez d'abord : cargo build --release --manifest-path agent/Cargo.toml"
exit 1
fi
if [ ! -f "$CONFIG" ]; then
echo "ERREUR : config.toml introuvable : $CONFIG"
echo "Copiez agent/config.toml.example vers agent/config.toml et ajustez l'IP du serveur."
exit 1
fi
echo "[1/5] Copie du binaire vers /usr/local/bin/"
cp "$BINARY" /usr/local/bin/nanometrics-agent
chmod 755 /usr/local/bin/nanometrics-agent
echo "[2/5] Création du répertoire de configuration /etc/nanometrics/"
mkdir -p /etc/nanometrics
chmod 755 /etc/nanometrics
echo "[3/5] Copie de config.toml vers /etc/nanometrics/"
cp "$CONFIG" /etc/nanometrics/config.toml
chmod 644 /etc/nanometrics/config.toml
echo "[4/5] Installation du service systemd"
cp "$SERVICE" /etc/systemd/system/nanometrics-agent.service
systemctl daemon-reload
systemctl enable nanometrics-agent
echo "[5/5] Démarrage du service"
systemctl restart nanometrics-agent
sleep 2
echo ""
echo "=== Statut ==="
systemctl status nanometrics-agent --no-pager
+205
View File
@@ -0,0 +1,205 @@
#!/usr/bin/env bash
# Installe l'agent Nanometrics depuis la dernière release Gitea.
# Usage :
# curl -fsSL https://git.maison43gil.com/gilles/nano_metrics/raw/branch/main/deploy/install.sh | bash
# SERVER_IP=10.0.0.50 SERVER_PORT=9999 curl -fsSL ... | bash
set -euo pipefail
REPO_API="https://git.maison43gil.com/api/v1/repos/gilles/nano_metrics"
SERVICE_URL="https://git.maison43gil.com/gilles/nano_metrics/raw/branch/main/deploy/nanometrics-agent.service"
INSTALL_BIN="/usr/local/bin/nanometrics-agent"
CONFIG_DIR="/etc/nanometrics"
CONFIG_FILE="$CONFIG_DIR/config.toml"
SERVICE_FILE="/etc/systemd/system/nanometrics-agent.service"
# ── Couleurs ───────────────────────────────────────────────────────────────────
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
ok() { echo -e "${GREEN}${NC} $*"; }
warn() { echo -e "${YELLOW}!${NC} $*"; }
err() { echo -e "${RED}${NC} $*" >&2; }
# ── Root check ─────────────────────────────────────────────────────────────────
if [ "$(id -u)" -ne 0 ]; then
err "Ce script doit être lancé en root (sudo bash ou sudo curl | bash)"
exit 1
fi
echo "======================================"
echo " Nanometrics Agent — Installation"
echo "======================================"
echo ""
# ── 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)"
case "$ARCH" in
x86_64) LABEL="linux-amd64" ;;
aarch64) LABEL="linux-arm64" ;;
*)
err "Architecture non supportée : $ARCH"
err "Seules x86_64 et aarch64 sont supportées."
exit 1
;;
esac
ok "Architecture détectée : $ARCH$LABEL"
# ── 4. Récupérer l'URL du binaire depuis la dernière release ──────────────────
echo "→ Récupération de la dernière release..."
ASSETS_JSON=$(curl -sf "$REPO_API/releases?limit=1&page=1")
ASSET_URL=$(echo "$ASSETS_JSON" | python3 -c "
import sys, json
releases = json.load(sys.stdin)
if not releases:
raise SystemExit('Aucune release trouvée sur le dépôt.')
assets = releases[0].get('assets', [])
name = 'nanometrics-agent-$LABEL'
for a in assets:
if a['name'] == name:
print(a['browser_download_url'])
break
else:
raise SystemExit(f'Asset {name!r} introuvable dans la release.')
")
TAG=$(echo "$ASSETS_JSON" | python3 -c "
import sys, json
releases = json.load(sys.stdin)
print(releases[0]['tag_name'])
")
ok "Release : $TAG — URL : $ASSET_URL"
# ── 5. Télécharger le binaire ─────────────────────────────────────────────────
TMP_BIN="$(mktemp)"
trap 'rm -f "$TMP_BIN"' EXIT
echo "→ Téléchargement du binaire..."
curl -fsSL -o "$TMP_BIN" "$ASSET_URL"
chmod 755 "$TMP_BIN"
ok "Binaire téléchargé ($(du -sh "$TMP_BIN" | cut -f1))"
# ── 6. Paramètres de configuration ────────────────────────────────────────────
echo ""
echo "--- Configuration du serveur ---"
SERVER_IP="${SERVER_IP:-10.0.0.50}"
SERVER_PORT="${SERVER_PORT:-9999}"
MQTT_HOST="${MQTT_HOST:-10.0.0.3}"
MQTT_ENABLED="${MQTT_ENABLED:-false}"
ok "Serveur : $SERVER_IP:$SERVER_PORT | MQTT broker : $MQTT_HOST"
# ── 7. Installer le binaire ────────────────────────────────────────────────────
echo ""
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"
chmod 755 "$INSTALL_BIN"
ok "Binaire installé"
# ── 8. Créer le répertoire de configuration ───────────────────────────────────
echo "[2/5] Création de $CONFIG_DIR"
mkdir -p "$CONFIG_DIR"
chmod 755 "$CONFIG_DIR"
ok "Répertoire créé"
# ── 9. Écrire config.toml ─────────────────────────────────────────────────────
echo "[3/5] Écriture de $CONFIG_FILE"
cat > "$CONFIG_FILE" << TOML
[server]
ip = "$SERVER_IP"
port = $SERVER_PORT
[protocols.udp]
enabled = true
[protocols.mqtt]
enabled = $MQTT_ENABLED
host = "$MQTT_HOST"
port = 1883
topic_base = "nanometrics/agents"
auto_discovery = true
birth_message = true
last_will = true
[metrics.cpu]
udp = true
mqtt = false
[metrics.memory]
udp = true
mqtt = false
[metrics.disk]
udp = true
mqtt = false
[metrics.network]
udp = false
mqtt = false
[metrics.uptime]
udp = true
mqtt = false
[metrics.temperature]
udp = true
mqtt = false
[metrics.smart]
udp = true
mqtt = false
TOML
chmod 644 "$CONFIG_FILE"
ok "config.toml écrit"
# 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"
curl -fsSL -o "$SERVICE_FILE" "$SERVICE_URL"
chmod 644 "$SERVICE_FILE"
systemctl daemon-reload
systemctl enable nanometrics-agent
ok "Service installé et activé"
# ── 11. Démarrer le service ───────────────────────────────────────────────────
echo "[5/5] Démarrage du service"
systemctl restart nanometrics-agent
sleep 2
echo ""
echo "=== Statut ==="
systemctl status nanometrics-agent --no-pager || true
echo ""
echo "======================================"
echo -e " ${GREEN}${NC} Nanometrics Agent ${TAG} installé"
echo "======================================"
echo " Config : $CONFIG_FILE"
echo " Logs : journalctl -u nanometrics-agent -f"
+2 -1
View File
@@ -10,8 +10,9 @@ Restart=on-failure
RestartSec=5
DynamicUser=yes
SupplementaryGroups=disk
ConfigurationDirectory=nanometrics
ConfigurationDirectoryMode=0750
ConfigurationDirectoryMode=0755
ProtectSystem=strict
ProtectHome=read-only
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env bash
# Compile le binaire release et publie une release Gitea avec le binaire en asset.
# Usage : ./deploy/release.sh v0.1.0
# ./deploy/release.sh v0.1.1 "Description du changement"
set -euo pipefail
REPO_API="https://git.maison43gil.com/api/v1/repos/gilles/nano_metrics"
TOKEN_FILE="$(dirname "$0")/../repo.md"
# Lire le token depuis repo.md
TOKEN=$(grep -oP '(?<=\*\*Token\*\* : ).*' "$TOKEN_FILE" | tr -d '[:space:]')
if [ -z "$TOKEN" ]; then
echo "ERREUR : token Gitea introuvable dans repo.md"
exit 1
fi
TAG="${1:-}"
if [ -z "$TAG" ]; then
echo "Usage : $0 <tag> [description]"
echo "Exemple : $0 v0.1.0"
exit 1
fi
DESCRIPTION="${2:-Release $TAG}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
CARGO_TOML="$ROOT/agent/Cargo.toml"
mkdir -p "$ROOT/dist"
# ── 1. Compiler l'agent pour toutes les cibles supportées ────────────────
echo "=== Compilation de l'agent ==="
TARGETS=("x86_64-unknown-linux-musl" "aarch64-unknown-linux-musl")
LABELS=("linux-amd64" "linux-arm64")
mkdir -p "$ROOT/dist"
for i in "${!TARGETS[@]}"; do
TARGET="${TARGETS[$i]}"
LABEL="${LABELS[$i]}"
echo "$TARGET ($LABEL)..."
# Installer la cible si absente
rustup target add "$TARGET" 2>/dev/null || true
# 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 \
--manifest-path "$CARGO_TOML" \
--target "$TARGET" \
2>&1 | tail -3
SRC="$ROOT/agent/target/$TARGET/release/nanometrics-agent"
if [ ! -f "$SRC" ]; then
echo " AVERTISSEMENT : binaire introuvable pour $TARGET, ignoré"
continue
fi
DEST="$ROOT/dist/nanometrics-agent-$LABEL"
cp "$SRC" "$DEST"
strip "$DEST" 2>/dev/null || true
echo " OK : $(du -sh "$DEST" | cut -f1)"
done
# ── 2. Créer la release Gitea ──────────────────────────────────────────────
echo ""
echo "=== Création de la release Gitea $TAG ==="
RELEASE_JSON=$(curl -sf \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-X POST "$REPO_API/releases" \
-d "{
\"tag_name\": \"$TAG\",
\"name\": \"$TAG\",
\"body\": \"$DESCRIPTION\",
\"draft\": false,
\"prerelease\": false
}")
RELEASE_ID=$(echo "$RELEASE_JSON" | python3 -c "import sys,json; print(json.load(sys.stdin)['id'])")
echo "Release ID : $RELEASE_ID"
# ── 3. Uploader les binaires comme assets ─────────────────────────────────
echo ""
echo "=== Upload des binaires ==="
for LABEL in "${LABELS[@]}"; do
ASSET="$ROOT/dist/nanometrics-agent-$LABEL"
[ -f "$ASSET" ] || continue
ASSET_NAME="nanometrics-agent-$LABEL"
echo "→ Upload $ASSET_NAME..."
curl -sf \
-H "Authorization: token $TOKEN" \
-F "attachment=@$ASSET;filename=$ASSET_NAME" \
"$REPO_API/releases/$RELEASE_ID/assets" \
> /dev/null
echo " OK"
done
echo ""
echo "✓ Release $TAG publiée : https://git.maison43gil.com/gilles/nano_metrics/releases/tag/$TAG"
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+14
View File
@@ -0,0 +1,14 @@
ARG GO_IMAGE=public.ecr.aws/docker/library/golang:1.25-alpine
FROM ${GO_IMAGE} AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o nanometrics-server .
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/nanometrics-server /nanometrics-server
VOLUME /data
EXPOSE 8080 9999/udp
CMD ["/nanometrics-server"]
+8
View File
@@ -0,0 +1,8 @@
# Dockerfile de dev : utilise le binaire pré-compilé localement (pas de pull Docker Hub)
ARG NGINX_IMAGE=public.ecr.aws/docker/library/nginx:alpine
FROM ${NGINX_IMAGE}
COPY nanometrics-server /app/nanometrics-server
WORKDIR /app
VOLUME /data
EXPOSE 8080 9999/udp
CMD ["./nanometrics-server"]
+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"]
+40
View File
@@ -0,0 +1,40 @@
package config
import "os"
type Config struct {
UDPAddr string
DBPath string
HTTPAddr string
MQTTBroker string
MQTTTopicBase string
DashboardDir string // optionnel : sert le dashboard en dev sans Nginx
}
func Load() Config {
return Config{
UDPAddr: getEnv("UDP_ADDR", "0.0.0.0:9999"),
DBPath: getEnv("DB_PATH", "/data/nanometrics.db"),
HTTPAddr: getEnv("HTTP_ADDR", "0.0.0.0:8080"),
MQTTBroker: getEnv("MQTT_BROKER", "tcp://10.0.0.3:1883"),
MQTTTopicBase: getEnv("MQTT_TOPIC_BASE", "nanometrics/agents"),
DashboardDir: getEnv("DASHBOARD_DIR", ""),
}
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func Default() Config {
return Config{
UDPAddr: "127.0.0.1:19999",
DBPath: ":memory:",
HTTPAddr: "127.0.0.1:18080",
MQTTBroker: "tcp://127.0.0.1:11883",
MQTTTopicBase: "test/nanometrics",
}
}
+365
View File
@@ -0,0 +1,365 @@
package db
import (
"database/sql"
"encoding/json"
"fmt"
"log"
"time"
_ "modernc.org/sqlite"
"github.com/user/nanometrics/server/models"
)
type DB struct {
conn *sql.DB
}
const schema = `
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY, hostname TEXT NOT NULL,
ip TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'offline',
last_seen INTEGER NOT NULL DEFAULT 0, version TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT, agent_id TEXT NOT NULL, ts INTEGER NOT NULL,
cpu_percent REAL, memory_used INTEGER, memory_free INTEGER, memory_total INTEGER,
hdd_used INTEGER, hdd_free INTEGER, hdd_total INTEGER,
uptime INTEGER, network_rx INTEGER, network_tx INTEGER, temperature REAL,
smart_passed INTEGER, smart_temp INTEGER, smart_realloc INTEGER,
smart_hours INTEGER, smart_wear INTEGER,
FOREIGN KEY (agent_id) REFERENCES agents(id)
);
CREATE INDEX IF NOT EXISTS idx_metrics_agent_ts ON metrics(agent_id, ts);
CREATE TABLE IF NOT EXISTS agent_configs (
agent_id TEXT PRIMARY KEY, config_json TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY (agent_id) REFERENCES agents(id)
);
CREATE TABLE IF NOT EXISTS agent_icons (
agent_id TEXT PRIMARY KEY, data BLOB NOT NULL, mime_type TEXT NOT NULL DEFAULT 'image/png',
FOREIGN KEY (agent_id) REFERENCES agents(id)
);
CREATE TABLE IF NOT EXISTS server_config (key TEXT PRIMARY KEY, value TEXT NOT NULL);
`
func Open(path string) (*DB, error) {
conn, err := sql.Open("sqlite", path)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
conn.SetMaxOpenConns(1)
d := &DB{conn: conn}
if err := d.migrate(); err != nil {
return nil, fmt.Errorf("migrate: %w", err)
}
return d, nil
}
func (d *DB) migrate() error {
if _, err := d.conn.Exec(schema); err != nil {
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) UpsertAgent(m *models.AgentMetrics) error {
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(`
INSERT INTO agents (id, hostname, ip, status, last_seen, version,
network_info_json, hardware_info_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
ip=excluded.ip, status=excluded.status, last_seen=excluded.last_seen,
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
}
func (d *DB) InsertMetrics(m *models.AgentMetrics) error {
ts := time.Now().Unix()
var smartJSON interface{}
if len(m.Smart) > 0 {
if b, err := json.Marshal(m.Smart); err == nil {
smartJSON = string(b)
}
}
_, err := d.conn.Exec(`
INSERT INTO metrics (agent_id, ts,
cpu_percent, memory_used, memory_free, memory_total,
hdd_used, hdd_free, hdd_total,
uptime, network_rx, network_tx, temperature,
smart_json)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
m.Hostname, ts,
m.CPUPercent, m.MemoryUsed, m.MemoryFree, m.MemoryTotal,
m.HDDUsed, m.HDDFree, m.HDDTotal,
m.Uptime, m.NetworkRX, m.NetworkTX, m.Temperature,
smartJSON)
return err
}
func (d *DB) GetAgents() ([]models.Agent, error) {
rows, err := d.conn.Query(`SELECT id, hostname, ip, status, last_seen, version,
network_info_json, hardware_info_json FROM agents`)
if err != nil {
return nil, err
}
defer rows.Close()
var agents []models.Agent
for rows.Next() {
var a models.Agent
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
}
if netJSON != nil {
_ = json.Unmarshal([]byte(*netJSON), &a.NetworkInfo)
}
if hwJSON != nil {
_ = json.Unmarshal([]byte(*hwJSON), &a.HardwareInfo)
}
agents = append(agents, a)
}
if err := rows.Err(); err != nil {
return nil, err
}
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) {
rows, err := d.conn.Query(`
SELECT ts, cpu_percent, memory_used, memory_total, hdd_used, hdd_total
FROM metrics
WHERE agent_id = ? AND ts >= ? AND ts <= ?
ORDER BY ts ASC`, agentID, from, to)
if err != nil {
return nil, err
}
defer rows.Close()
var result []map[string]interface{}
for rows.Next() {
var ts int64
var cpu, memUsed, memTotal, hddUsed, hddTotal interface{}
if err := rows.Scan(&ts, &cpu, &memUsed, &memTotal, &hddUsed, &hddTotal); err != nil {
return nil, err
}
result = append(result, map[string]interface{}{
"ts": ts, "cpu_percent": cpu,
"memory_used": memUsed, "memory_total": memTotal,
"hdd_used": hddUsed, "hdd_total": hddTotal,
})
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}
func (d *DB) GetAgentConfig(agentID string) (*models.AgentConfig, error) {
var raw string
err := d.conn.QueryRow(
`SELECT config_json FROM agent_configs WHERE agent_id = ?`, agentID,
).Scan(&raw)
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
var cfg models.AgentConfig
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
func (d *DB) UpsertAgentConfig(agentID string, cfg *models.AgentConfig) error {
raw, err := json.Marshal(cfg)
if err != nil {
return err
}
d.conn.Exec(`INSERT OR IGNORE INTO agents (id, hostname, ip, status, last_seen) VALUES (?,?,?,?,?)`,
agentID, agentID, "", "offline", 0)
_, err = d.conn.Exec(`
INSERT INTO agent_configs (agent_id, config_json)
VALUES (?, ?)
ON CONFLICT(agent_id) DO UPDATE SET config_json=excluded.config_json`,
agentID, string(raw))
return err
}
func (d *DB) GetServerConfig() (models.ServerConfig, error) {
cfg := models.DefaultServerConfig()
var raw string
if err := d.conn.QueryRow(`SELECT value FROM server_config WHERE key='ui'`).Scan(&raw); err == nil {
_ = json.Unmarshal([]byte(raw), &cfg)
}
return cfg, nil
}
func (d *DB) SetServerConfig(cfg models.ServerConfig) error {
raw, err := json.Marshal(cfg)
if err != nil {
return err
}
_, err = d.conn.Exec(`
INSERT INTO server_config (key, value) VALUES ('ui', ?)
ON CONFLICT(key) DO UPDATE SET value=excluded.value`, string(raw))
return err
}
func (d *DB) SaveIcon(agentID string, data []byte, mimeType string) error {
d.conn.Exec(`INSERT OR IGNORE INTO agents (id, hostname, ip, status, last_seen) VALUES (?,?,?,?,?)`,
agentID, agentID, "", "offline", 0)
_, err := d.conn.Exec(`
INSERT INTO agent_icons (agent_id, data, mime_type) VALUES (?,?,?)
ON CONFLICT(agent_id) DO UPDATE SET data=excluded.data, mime_type=excluded.mime_type`,
agentID, data, mimeType)
return err
}
func (d *DB) GetIcon(agentID string) ([]byte, string, error) {
var data []byte
var mime string
err := d.conn.QueryRow(
`SELECT data, mime_type FROM agent_icons WHERE agent_id=?`, agentID,
).Scan(&data, &mime)
if err != nil {
return nil, "", err
}
return data, mime, nil
}
func (d *DB) PruneOldMetrics(retentionDays int) error {
cutoff := time.Now().Unix() - int64(retentionDays)*86400
_, err := d.conn.Exec(`DELETE FROM metrics WHERE ts < ?`, cutoff)
return err
}
func (d *DB) MarkOffline(timeoutSec int64) error {
_, err := d.MarkOfflineAndGetIDs(timeoutSec)
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.
func (d *DB) MarkOfflineAndGetIDs(timeoutSec int64) ([]string, error) {
cutoff := time.Now().Unix() - timeoutSec
rows, err := d.conn.Query(
`SELECT id FROM agents WHERE last_seen < ? AND status != 'offline'`, cutoff)
if err != nil {
return nil, err
}
var ids []string
for rows.Next() {
var id string
_ = rows.Scan(&id)
ids = append(ids, id)
}
if err = rows.Err(); err != nil {
return nil, err
}
rows.Close()
if len(ids) > 0 {
_, err = d.conn.Exec(
`UPDATE agents SET status='offline' WHERE last_seen < ? AND status != 'offline'`, cutoff)
}
return ids, err
}
func init() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
+88
View File
@@ -0,0 +1,88 @@
package db_test
import (
"testing"
"github.com/user/nanometrics/server/db"
"github.com/user/nanometrics/server/models"
)
func newTestDB(t *testing.T) *db.DB {
t.Helper()
d, err := db.Open(":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { d.Close() })
return d
}
func TestUpsertAndGetAgents(t *testing.T) {
d := newTestDB(t)
m := &models.AgentMetrics{Hostname: "srv-01", IP: "10.0.0.1", Status: "online"}
if err := d.UpsertAgent(m); err != nil {
t.Fatalf("upsert: %v", err)
}
agents, err := d.GetAgents()
if err != nil {
t.Fatalf("get agents: %v", err)
}
if len(agents) != 1 {
t.Fatalf("attendu 1 agent, eu %d", len(agents))
}
if agents[0].Hostname != "srv-01" {
t.Errorf("hostname: attendu srv-01, eu %s", agents[0].Hostname)
}
}
func TestInsertMetrics(t *testing.T) {
d := newTestDB(t)
cpu := 42.5
m := &models.AgentMetrics{Hostname: "srv-01", IP: "10.0.0.1", Status: "online", CPUPercent: &cpu}
_ = d.UpsertAgent(m)
if err := d.InsertMetrics(m); err != nil {
t.Fatalf("insert metrics: %v", err)
}
history, err := d.GetMetricsHistory("srv-01", 0, 9999999999)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 1 {
t.Fatalf("attendu 1 entrée, eu %d", len(history))
}
}
func TestAgentConfig(t *testing.T) {
d := newTestDB(t)
cfg := &models.AgentConfig{
Metrics: models.MetricsConfig{
CPU: models.MetricProto{UDP: true, MQTT: false},
},
}
if err := d.UpsertAgentConfig("srv-01", cfg); err != nil {
t.Fatalf("upsert config: %v", err)
}
got, err := d.GetAgentConfig("srv-01")
if err != nil || got == nil {
t.Fatalf("get config: %v", err)
}
if !got.Metrics.CPU.UDP {
t.Error("CPU.UDP devrait être true")
}
}
func TestServerConfig(t *testing.T) {
d := newTestDB(t)
cfg := models.DefaultServerConfig()
cfg.TileMinWidth = 300
if err := d.SetServerConfig(cfg); err != nil {
t.Fatalf("set config: %v", err)
}
got, err := d.GetServerConfig()
if err != nil {
t.Fatalf("get config: %v", err)
}
if got.TileMinWidth != 300 {
t.Errorf("tile_min_width: attendu 300, eu %d", got.TileMinWidth)
}
}
+29
View File
@@ -0,0 +1,29 @@
version: '3.8'
services:
server:
image: nanometrics-server:dev
restart: unless-stopped
environment:
UDP_ADDR: "0.0.0.0:9999"
DB_PATH: "/data/nanometrics.db"
HTTP_ADDR: "0.0.0.0:8080"
MQTT_BROKER: "tcp://10.0.0.3:1883"
MQTT_TOPIC_BASE: "nanometrics/agents"
volumes:
- nanometrics_data:/data
ports:
- "9999:9999/udp"
dashboard:
image: ${NGINX_IMAGE:-public.ecr.aws/docker/library/nginx:alpine}
restart: unless-stopped
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ../dashboard:/usr/share/nginx/html:ro
ports:
- "8888:80"
depends_on:
- server
volumes:
nanometrics_data:
+45
View File
@@ -0,0 +1,45 @@
services:
server:
build:
context: .
pull: false
args:
GO_IMAGE: ${GO_IMAGE:-public.ecr.aws/docker/library/golang:1.25-alpine}
restart: unless-stopped
environment:
UDP_ADDR: "0.0.0.0:9999"
DB_PATH: "/data/nanometrics.db"
HTTP_ADDR: "0.0.0.0:8080"
MQTT_BROKER: "tcp://10.0.0.3:1883"
MQTT_TOPIC_BASE: "nanometrics/agents"
volumes:
- nanometrics_data:/data
ports:
- "9999:9999/udp"
dashboard:
image: ${NGINX_IMAGE:-public.ecr.aws/docker/library/nginx:alpine}
pull_policy: if_not_present
restart: unless-stopped
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ../dashboard:/usr/share/nginx/html:ro
ports:
- "80:80"
depends_on:
- 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:
nanometrics_data:
+31
View File
@@ -0,0 +1,31 @@
module github.com/user/nanometrics/server
go 1.25.0
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/disintegration/imaging v1.6.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
modernc.org/libc v1.72.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.50.1 // indirect
)
+53
View File
@@ -0,0 +1,53 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
modernc.org/libc v1.72.3 h1:ZnDF4tXn4NBXFutMMQC4vtbTFSXhhKzR73fv0beZEAU=
modernc.org/libc v1.72.3/go.mod h1:dn0dZNnnn1clLyvRxLxYExxiKRZIRENOfqQ8XEeg4Qs=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.50.1 h1:l+cQvn0sd0zJJtfygGHuQJ5AjlrwXmWPw4KP3ZMwr9w=
modernc.org/sqlite v1.50.1/go.mod h1:tcNzv5p84E0skkmJn038y+hWJbLQXQqEnQfeh5r2JLM=
+67
View File
@@ -0,0 +1,67 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/user/nanometrics/server/db"
"github.com/user/nanometrics/server/models"
)
func AgentsHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
agents, err := database.GetAgents()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
for i := range agents {
agents[i].LastMetrics, _ = database.GetLastMetrics(agents[i].ID)
}
w.Header().Set("Content-Type", "application/json")
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)
}
}
+83
View File
@@ -0,0 +1,83 @@
package handlers
import (
"encoding/json"
"net/http"
"strings"
"github.com/user/nanometrics/server/db"
"github.com/user/nanometrics/server/models"
)
func AgentConfigHandler(database *db.DB, pushConfig func(agentID string, cfg *models.AgentConfig)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 4 {
http.Error(w, "invalid path", 400)
return
}
agentID := parts[2]
switch r.Method {
case http.MethodGet:
cfg, err := database.GetAgentConfig(agentID)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
if cfg == nil {
cfg = models.DefaultAgentConfig()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cfg)
case http.MethodPut:
var cfg models.AgentConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
http.Error(w, err.Error(), 400)
return
}
if err := database.UpsertAgentConfig(agentID, &cfg); err != nil {
http.Error(w, err.Error(), 500)
return
}
if pushConfig != nil {
go pushConfig(agentID, &cfg)
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "method not allowed", 405)
}
}
}
func ServerConfigHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cfg, err := database.GetServerConfig()
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(cfg)
case http.MethodPut:
var cfg models.ServerConfig
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
http.Error(w, err.Error(), 400)
return
}
if err := database.SetServerConfig(cfg); err != nil {
http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "method not allowed", 405)
}
}
}
+72
View File
@@ -0,0 +1,72 @@
package handlers_test
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/user/nanometrics/server/db"
"github.com/user/nanometrics/server/handlers"
"github.com/user/nanometrics/server/models"
)
func testDB(t *testing.T) *db.DB {
d, _ := db.Open(":memory:")
t.Cleanup(func() { d.Close() })
return d
}
func TestServerConfigGetPut(t *testing.T) {
d := testDB(t)
h := handlers.ServerConfigHandler(d)
r := httptest.NewRequest(http.MethodGet, "/api/config", nil)
w := httptest.NewRecorder()
h(w, r)
if w.Code != 200 {
t.Fatalf("GET status: %d", w.Code)
}
var got models.ServerConfig
json.NewDecoder(w.Body).Decode(&got)
if got.TileMinWidth != 220 {
t.Errorf("tile_min_width défaut: %d", got.TileMinWidth)
}
cfg := models.DefaultServerConfig()
cfg.TileMinWidth = 300
body, _ := json.Marshal(cfg)
r2 := httptest.NewRequest(http.MethodPut, "/api/config", bytes.NewReader(body))
w2 := httptest.NewRecorder()
h(w2, r2)
if w2.Code != 204 {
t.Fatalf("PUT status: %d", w2.Code)
}
r3 := httptest.NewRequest(http.MethodGet, "/api/config", nil)
w3 := httptest.NewRecorder()
h(w3, r3)
var got2 models.ServerConfig
json.NewDecoder(w3.Body).Decode(&got2)
if got2.TileMinWidth != 300 {
t.Errorf("tile_min_width après PUT: %d", got2.TileMinWidth)
}
}
func TestAgentsHandler(t *testing.T) {
d := testDB(t)
h := handlers.AgentsHandler(d)
r := httptest.NewRequest(http.MethodGet, "/api/agents", nil)
w := httptest.NewRecorder()
h(w, r)
if w.Code != 200 {
t.Fatalf("status: %d", w.Code)
}
var agents []models.Agent
json.NewDecoder(w.Body).Decode(&agents)
if agents == nil {
// tableau vide attendu, pas d'erreur
}
}
+89
View File
@@ -0,0 +1,89 @@
package handlers
import (
"bytes"
"image"
_ "image/gif"
_ "image/jpeg"
"image/png"
"io"
"net/http"
"strings"
"github.com/disintegration/imaging"
"github.com/user/nanometrics/server/db"
_ "golang.org/x/image/webp"
)
const maxIconSize = 128
func IconUploadHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", 405)
return
}
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 4 {
http.Error(w, "invalid path", 400)
return
}
agentID := parts[2]
r.ParseMultipartForm(2 << 20)
file, header, err := r.FormFile("icon")
if err != nil {
http.Error(w, "fichier manquant", 400)
return
}
defer file.Close()
mime := header.Header.Get("Content-Type")
if mime == "" {
mime = "image/png"
}
// SVG refusé (risque XSS)
if strings.Contains(mime, "svg") {
http.Error(w, "SVG non supporté — utilisez PNG, JPG ou WEBP", 400)
return
}
// Limite de taille
limited := io.LimitReader(file, 2<<20)
img, _, err := image.Decode(limited)
if err != nil {
http.Error(w, "image invalide", 400)
return
}
resized := imaging.Fit(img, maxIconSize, maxIconSize, imaging.Lanczos)
var buf bytes.Buffer
if err := png.Encode(&buf, resized); err != nil {
http.Error(w, err.Error(), 500)
return
}
if err := database.SaveIcon(agentID, buf.Bytes(), "image/png"); err != nil {
http.Error(w, err.Error(), 500)
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func IconGetHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 4 {
http.Error(w, "invalid path", 400)
return
}
agentID := parts[2]
data, mime, err := database.GetIcon(agentID)
if err != nil {
http.Error(w, "not found", 404)
return
}
w.Header().Set("Content-Type", mime)
w.Write(data)
}
}
+45
View File
@@ -0,0 +1,45 @@
package handlers
import (
"encoding/json"
"net/http"
"strconv"
"strings"
"time"
"github.com/user/nanometrics/server/db"
)
func MetricsHistoryHandler(database *db.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 4 {
http.Error(w, "invalid path", 400)
return
}
agentID := parts[2]
now := time.Now().Unix()
from := now - 3600
to := now
if v := r.URL.Query().Get("from"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
from = n
}
}
if v := r.URL.Query().Get("to"); v != "" {
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
to = n
}
}
history, err := database.GetMetricsHistory(agentID, from, to)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(history)
}
}
+226
View File
@@ -0,0 +1,226 @@
package main
import (
"bufio"
"fmt"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/user/nanometrics/server/config"
"github.com/user/nanometrics/server/db"
"github.com/user/nanometrics/server/handlers"
"github.com/user/nanometrics/server/models"
prom "github.com/user/nanometrics/server/prometheus"
"github.com/user/nanometrics/server/transport"
ws "github.com/user/nanometrics/server/websocket"
)
const serverVersion = "0.1.0"
func main() {
cfg := config.Load()
database, err := db.Open(cfg.DBPath)
if err != nil {
log.Fatalf("DB: %v", err)
}
hub := ws.NewHub()
onMetrics := func(m *models.AgentMetrics) {
if err := database.UpsertAgent(m); err != nil {
log.Printf("[ingest] upsert agent: %v", err)
}
if err := database.InsertMetrics(m); err != nil {
log.Printf("[ingest] insert metrics: %v", err)
}
prom.Update(m)
hub.Broadcast(models.WSMessage{
Type: "metrics_update",
AgentID: m.Hostname,
Data: m,
})
}
if err := transport.StartUDP(cfg.UDPAddr, onMetrics); err != nil {
log.Fatalf("UDP: %v", err)
}
var mqttClient *transport.MQTTClient
if mc, err := transport.StartMQTT(cfg.MQTTBroker, cfg.MQTTTopicBase, onMetrics); err != nil {
log.Printf("[mqtt] non disponible: %v", err)
} else {
mqttClient = mc
}
pushConfig := func(agentID string, agentCfg *models.AgentConfig) {
if mqttClient != nil {
if err := mqttClient.PushConfig(agentID, agentCfg); err != nil {
log.Printf("[mqtt] push config to %s: %v", agentID, err)
}
}
}
// Maintenance : nettoyage + détection offline avec notification WS
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
srvCfg, _ := database.GetServerConfig()
_ = database.PruneOldMetrics(srvCfg.RetentionDays)
ids, _ := database.MarkOfflineAndGetIDs(30)
for _, id := range ids {
hub.Broadcast(models.WSMessage{
Type: "status_update",
AgentID: id,
Data: map[string]string{"status": "offline"},
})
}
}
}()
// Métriques du serveur lui-même → footer du dashboard
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
var prevIdle, prevTotal uint64
for range ticker.C {
stats, err := collectServerStats(&prevIdle, &prevTotal)
if err != nil {
continue
}
stats.Version = serverVersion
hub.Broadcast(models.WSMessage{Type: "server_stats", Data: stats})
}
}()
mux := http.NewServeMux()
mux.Handle("/metrics", promhttp.Handler())
mux.Handle("/ws", ws.Handler(hub))
mux.HandleFunc("/api/agents", handlers.AgentsHandler(database))
mux.HandleFunc("/api/agents/", func(w http.ResponseWriter, r *http.Request) {
switch {
case endsWith(r.URL.Path, "/history"):
handlers.MetricsHistoryHandler(database)(w, r)
case endsWith(r.URL.Path, "/config"):
handlers.AgentConfigHandler(database, pushConfig)(w, r)
case endsWith(r.URL.Path, "/icon") && r.Method == http.MethodPost:
handlers.IconUploadHandler(database)(w, r)
case endsWith(r.URL.Path, "/icon") && r.Method == http.MethodGet:
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:
http.NotFound(w, r)
}
})
mux.HandleFunc("/api/config", handlers.ServerConfigHandler(database))
if cfg.DashboardDir != "" {
log.Printf("[http] dashboard servi depuis %s", cfg.DashboardDir)
mux.Handle("/", http.FileServer(http.Dir(cfg.DashboardDir)))
}
log.Printf("[http] écoute sur %s", cfg.HTTPAddr)
log.Fatal(http.ListenAndServe(cfg.HTTPAddr, mux))
}
func endsWith(path, suffix string) bool {
return len(path) >= len(suffix) && path[len(path)-len(suffix):] == suffix
}
func collectServerStats(prevIdle, prevTotal *uint64) (*models.ServerStats, error) {
idle, total, err := readCPUStat()
if err != nil {
return nil, err
}
var cpuPct float64
if *prevTotal > 0 && total > *prevTotal {
deltaIdle := float64(idle - *prevIdle)
deltaTotal := float64(total - *prevTotal)
cpuPct = 100.0 * (1.0 - deltaIdle/deltaTotal)
if cpuPct < 0 {
cpuPct = 0
}
}
*prevIdle = idle
*prevTotal = total
memTotal, memAvail, err := readMemInfo()
if err != nil {
return nil, err
}
return &models.ServerStats{
CPUPercent: cpuPct,
MemUsed: memTotal - memAvail,
MemTotal: memTotal,
}, nil
}
func readCPUStat() (idle, total uint64, err error) {
f, err := os.Open("/proc/stat")
if err != nil {
return 0, 0, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "cpu ") {
continue
}
fields := strings.Fields(line)[1:]
var vals [10]uint64
for i, v := range fields {
if i >= 10 {
break
}
vals[i], _ = strconv.ParseUint(v, 10, 64)
}
// idle = idle + iowait
idle = vals[3] + vals[4]
for _, v := range vals {
total += v
}
return idle, total, nil
}
return 0, 0, fmt.Errorf("cpu line not found in /proc/stat")
}
func readMemInfo() (totalBytes, availBytes int64, err error) {
f, err := os.Open("/proc/meminfo")
if err != nil {
return 0, 0, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
var total, avail int64
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 2 {
continue
}
val, _ := strconv.ParseInt(fields[1], 10, 64)
switch fields[0] {
case "MemTotal:":
total = val * 1024
case "MemAvailable:":
avail = val * 1024
}
if total > 0 && avail > 0 {
break
}
}
if total == 0 {
return 0, 0, fmt.Errorf("MemTotal not found in /proc/meminfo")
}
return total, avail, nil
}
+154
View File
@@ -0,0 +1,154 @@
package models
type AgentMetrics struct {
Hostname string `json:"hostname"`
IP string `json:"ip"`
Status string `json:"status"`
Version string `json:"version"`
CPUPercent *float64 `json:"cpu_percent"`
MemoryUsed *int64 `json:"memory_used"`
MemoryFree *int64 `json:"memory_free"`
MemoryTotal *int64 `json:"memory_total"`
HDDUsed *int64 `json:"hdd_used"`
HDDFree *int64 `json:"hdd_free"`
HDDTotal *int64 `json:"hdd_total"`
Uptime *int64 `json:"uptime"`
NetworkRX *int64 `json:"network_rx"`
NetworkTX *int64 `json:"network_tx"`
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 {
Device string `json:"device"`
Passed bool `json:"passed"`
Temperature *int64 `json:"temperature"`
ReallocatedSectors *int64 `json:"reallocated_sectors"`
PowerOnHours *int64 `json:"power_on_hours"`
WearLevel *int64 `json:"wear_level"`
}
type Agent struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
IP string `json:"ip"`
Status string `json:"status"`
LastSeen int64 `json:"last_seen"`
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 {
Metrics MetricsConfig `json:"metrics"`
Protocols ProtocolsConfig `json:"protocols"`
}
type ProtocolsConfig struct {
UDP UDPConfig `json:"udp"`
MQTT MQTTConfig `json:"mqtt"`
}
type UDPConfig struct {
Enabled bool `json:"enabled"`
}
type MQTTConfig struct {
Enabled bool `json:"enabled"`
Host string `json:"host"`
Port int `json:"port"`
TopicBase string `json:"topic_base"`
AutoDiscovery bool `json:"auto_discovery"`
BirthMessage bool `json:"birth_message"`
LastWill bool `json:"last_will"`
}
type MetricsConfig struct {
CPU MetricProto `json:"cpu"`
Memory MetricProto `json:"memory"`
Disk MetricProto `json:"disk"`
Network MetricProto `json:"network"`
Uptime MetricProto `json:"uptime"`
Temperature MetricProto `json:"temperature"`
Smart MetricProto `json:"smart"`
}
type MetricProto struct {
UDP bool `json:"udp"`
MQTT bool `json:"mqtt"`
}
type ServerConfig struct {
TileMinWidth int `json:"tile_min_width"`
FontSize int `json:"font_size"`
WarnCPU int `json:"warn_cpu"`
ErrCPU int `json:"err_cpu"`
WarnDisk int `json:"warn_disk"`
RetentionDays int `json:"retention_days"`
ChartDurationMin int `json:"chart_duration_min"`
HideOffline bool `json:"hide_offline"`
Notifications bool `json:"notifications"`
PopupDetailW int `json:"popup_detail_w"`
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 {
return ServerConfig{
TileMinWidth: 220, FontSize: 13,
WarnCPU: 70, ErrCPU: 85, WarnDisk: 75,
RetentionDays: 30, ChartDurationMin: 30,
HideOffline: false, Notifications: true,
PopupDetailW: 560, PopupDetailH: 600,
GaugeType: "compact",
}
}
type WSMessage struct {
Type string `json:"type"`
AgentID string `json:"agent_id"`
Data interface{} `json:"data"`
}
type ServerStats struct {
CPUPercent float64 `json:"cpu_percent"`
MemUsed int64 `json:"mem_used"`
MemTotal int64 `json:"mem_total"`
Version string `json:"version"`
}
BIN
View File
Binary file not shown.
+23
View File
@@ -0,0 +1,23 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
client_max_body_size 10m;
location /api/ {
proxy_pass http://server:8080;
proxy_set_header Host $host;
}
location /ws {
proxy_pass http://server:8080/ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
location /metrics {
proxy_pass http://server:8080/metrics;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+71
View File
@@ -0,0 +1,71 @@
package prometheus
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/user/nanometrics/server/models"
)
var (
agentCPU = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "nanometrics_cpu_percent",
Help: "Pourcentage CPU de l'agent",
}, []string{"agent"})
agentMemUsed = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "nanometrics_memory_used_bytes",
Help: "RAM utilisée en octets",
}, []string{"agent"})
agentMemTotal = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "nanometrics_memory_total_bytes",
Help: "RAM totale en octets",
}, []string{"agent"})
agentDiskUsed = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "nanometrics_disk_used_bytes",
Help: "Disque utilisé en octets",
}, []string{"agent"})
agentDiskTotal = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "nanometrics_disk_total_bytes",
Help: "Disque total en octets",
}, []string{"agent"})
agentUptime = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "nanometrics_uptime_seconds",
Help: "Uptime de l'agent en secondes",
}, []string{"agent"})
agentStatus = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "nanometrics_agent_online",
Help: "1 si l'agent est en ligne, 0 sinon",
}, []string{"agent"})
)
func Update(m *models.AgentMetrics) {
l := prometheus.Labels{"agent": m.Hostname}
if m.CPUPercent != nil {
agentCPU.With(l).Set(*m.CPUPercent)
}
if m.MemoryUsed != nil {
agentMemUsed.With(l).Set(float64(*m.MemoryUsed))
}
if m.MemoryTotal != nil {
agentMemTotal.With(l).Set(float64(*m.MemoryTotal))
}
if m.HDDUsed != nil {
agentDiskUsed.With(l).Set(float64(*m.HDDUsed))
}
if m.HDDTotal != nil {
agentDiskTotal.With(l).Set(float64(*m.HDDTotal))
}
if m.Uptime != nil {
agentUptime.With(l).Set(float64(*m.Uptime))
}
online := 0.0
if m.Status == "online" {
online = 1.0
}
agentStatus.With(l).Set(online)
}
+61
View File
@@ -0,0 +1,61 @@
package transport
import (
"encoding/json"
"fmt"
"log"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/user/nanometrics/server/models"
)
type MQTTClient struct {
client mqtt.Client
topicBase string
}
func StartMQTT(broker, topicBase string, handler func(*models.AgentMetrics)) (*MQTTClient, error) {
mc := &MQTTClient{topicBase: topicBase}
opts := mqtt.NewClientOptions().
AddBroker(broker).
SetClientID("nanometrics-server").
SetAutoReconnect(true).
SetOnConnectHandler(func(c mqtt.Client) {
log.Printf("[mqtt] connecté à %s", broker)
topic := fmt.Sprintf("%s/+/metrics", topicBase)
if tok := c.Subscribe(topic, 0, nil); tok.Wait() && tok.Error() != nil {
log.Printf("[mqtt] subscribe error: %v", tok.Error())
}
}).
SetConnectionLostHandler(func(c mqtt.Client, err error) {
log.Printf("[mqtt] connexion perdue: %v", err)
}).
SetDefaultPublishHandler(func(_ mqtt.Client, msg mqtt.Message) {
var m models.AgentMetrics
if err := json.Unmarshal(msg.Payload(), &m); err != nil {
log.Printf("[mqtt] JSON invalide sur %s: %v", msg.Topic(), err)
return
}
if m.Hostname != "" {
handler(&m)
}
})
mc.client = mqtt.NewClient(opts)
if tok := mc.client.Connect(); tok.Wait() && tok.Error() != nil {
return nil, fmt.Errorf("mqtt connect: %w", tok.Error())
}
return mc, nil
}
func (mc *MQTTClient) PushConfig(hostname string, cfg *models.AgentConfig) error {
topic := fmt.Sprintf("%s/%s/config", mc.topicBase, hostname)
data, err := json.Marshal(cfg)
if err != nil {
return err
}
tok := mc.client.Publish(topic, 1, false, data)
tok.Wait()
return tok.Error()
}
+43
View File
@@ -0,0 +1,43 @@
package transport
import (
"encoding/json"
"log"
"net"
"github.com/user/nanometrics/server/models"
)
func StartUDP(addr string, handler func(*models.AgentMetrics)) error {
conn, err := net.ListenPacket("udp", addr)
if err != nil {
return err
}
log.Printf("[udp] écoute sur %s", addr)
go func() {
buf := make([]byte, 65535)
for {
n, _, err := conn.ReadFrom(buf)
if err != nil {
log.Printf("[udp] erreur lecture: %v", err)
continue
}
data := make([]byte, n)
copy(data, buf[:n])
go processUDP(data, handler)
}
}()
return nil
}
func processUDP(data []byte, handler func(*models.AgentMetrics)) {
var m models.AgentMetrics
if err := json.Unmarshal(data, &m); err != nil {
log.Printf("[udp] JSON invalide: %v", err)
return
}
if m.Hostname == "" {
return
}
handler(&m)
}
+44
View File
@@ -0,0 +1,44 @@
package transport_test
import (
"encoding/json"
"net"
"testing"
"time"
"github.com/user/nanometrics/server/models"
"github.com/user/nanometrics/server/transport"
)
func TestUDPReceive(t *testing.T) {
received := make(chan *models.AgentMetrics, 1)
err := transport.StartUDP("127.0.0.1:29999", func(m *models.AgentMetrics) {
received <- m
})
if err != nil {
t.Fatalf("start UDP: %v", err)
}
conn, err := net.Dial("udp", "127.0.0.1:29999")
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
cpu := 55.0
m := models.AgentMetrics{Hostname: "test-01", IP: "127.0.0.1", Status: "online", CPUPercent: &cpu}
data, _ := json.Marshal(m)
conn.Write(data)
select {
case got := <-received:
if got.Hostname != "test-01" {
t.Errorf("hostname: attendu test-01, eu %s", got.Hostname)
}
if got.CPUPercent == nil || *got.CPUPercent != 55.0 {
t.Error("cpu_percent incorrect")
}
case <-time.After(time.Second):
t.Error("timeout: aucune métrique reçue")
}
}
+34
View File
@@ -0,0 +1,34 @@
package websocket
import (
"log"
"net/http"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 4096,
CheckOrigin: func(r *http.Request) bool { return true },
}
func Handler(hub *Hub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("[ws] upgrade: %v", err)
return
}
hub.Register(conn)
defer func() {
hub.Unregister(conn)
conn.Close()
}()
for {
if _, _, err := conn.ReadMessage(); err != nil {
break
}
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package websocket
import (
"encoding/json"
"log"
"sync"
"github.com/gorilla/websocket"
)
type client struct {
conn *websocket.Conn
send chan []byte
mu sync.Mutex
}
type Hub struct {
mu sync.RWMutex
clients map[*websocket.Conn]*client
}
func NewHub() *Hub {
return &Hub{clients: make(map[*websocket.Conn]*client)}
}
func (h *Hub) Register(conn *websocket.Conn) {
c := &client{conn: conn, send: make(chan []byte, 64)}
h.mu.Lock()
h.clients[conn] = c
h.mu.Unlock()
go c.writePump()
}
func (h *Hub) Unregister(conn *websocket.Conn) {
h.mu.Lock()
if c, ok := h.clients[conn]; ok {
close(c.send)
delete(h.clients, conn)
}
h.mu.Unlock()
}
func (h *Hub) Broadcast(msg interface{}) {
data, err := json.Marshal(msg)
if err != nil {
log.Printf("[ws] marshal: %v", err)
return
}
h.mu.RLock()
defer h.mu.RUnlock()
for _, c := range h.clients {
select {
case c.send <- data:
default:
// canal plein, client lent — on skip
}
}
}
func (h *Hub) Count() int {
h.mu.RLock()
defer h.mu.RUnlock()
return len(h.clients)
}
func (c *client) writePump() {
defer c.conn.Close()
for data := range c.send {
if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil {
log.Printf("[ws] write: %v", err)
return
}
}
}
+40
View File
@@ -0,0 +1,40 @@
package websocket_test
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
wslib "github.com/gorilla/websocket"
"github.com/user/nanometrics/server/websocket"
)
func TestHubBroadcast(t *testing.T) {
hub := websocket.NewHub()
srv := httptest.NewServer(websocket.Handler(hub))
defer srv.Close()
url := "ws" + strings.TrimPrefix(srv.URL, "http") + "/ws"
conn, _, err := wslib.DefaultDialer.Dial(url, nil)
if err != nil {
t.Fatalf("dial: %v", err)
}
defer conn.Close()
time.Sleep(50 * time.Millisecond)
hub.Broadcast(map[string]string{"type": "test", "msg": "hello"})
conn.SetReadDeadline(time.Now().Add(time.Second))
_, data, err := conn.ReadMessage()
if err != nil {
t.Fatalf("read: %v", err)
}
var got map[string]string
json.Unmarshal(data, &got)
if got["msg"] != "hello" {
t.Errorf("attendu hello, eu %s", got["msg"])
}
}