Compare commits
@@ -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"
|
||||
@@ -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"}
|
||||
|
||||
@@ -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://<ip-serveur>** (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
|
||||
```
|
||||
@@ -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"
|
||||
@@ -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.2"
|
||||
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"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nanometrics-agent"
|
||||
version = "0.1.0"
|
||||
version = "0.1.2"
|
||||
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"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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;
|
||||
|
||||
@@ -34,7 +34,6 @@ fn main() {
|
||||
|
||||
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 {
|
||||
@@ -69,6 +68,7 @@ fn main() {
|
||||
hostname: hostname.clone(),
|
||||
ip: ip.clone(),
|
||||
status: "online".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -102,9 +102,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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub struct AgentMetrics {
|
||||
pub hostname: String,
|
||||
pub ip: String,
|
||||
pub status: String,
|
||||
pub version: String,
|
||||
pub cpu_percent: Option<f32>,
|
||||
pub memory_used: Option<u64>,
|
||||
pub memory_free: Option<u64>,
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
/* 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 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}
|
||||
.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)}
|
||||
.smart-btn{display:inline-flex;align-items:center;gap:8px;padding:7px 12px;border-radius:8px;
|
||||
border:1px solid var(--border-2);background:var(--bg-3);cursor:pointer;
|
||||
transition:background .12s,border-color .12s,transform .08s;font-family:var(--font-terminal);font-size:11px}
|
||||
.smart-btn:hover{background:var(--bg-4)}.smart-btn:active{transform:translateY(1px)}
|
||||
.smart-btn.ok{border-color:rgba(77,187,38,.3);color:var(--ok)}
|
||||
.smart-dot{width:7px;height:7px;border-radius:50%;background:var(--ok);box-shadow:0 0 5px var(--ok)}
|
||||
.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)}
|
||||
|
||||
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:var(--bg-1)}
|
||||
::-webkit-scrollbar-thumb{background:var(--bg-4);border-radius:3px}
|
||||
|
After Width: | Height: | Size: 173 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 235 B |
|
After Width: | Height: | Size: 306 B |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 576 B |
|
After Width: | Height: | Size: 768 B |
@@ -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 |
@@ -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" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4zr3E_BX0PnT8RD8yKxjOVmNeaAh.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/sharetechmono/v15/J7aHnp1uDWRBEqV98dVQztYldFc7pAsEIc3Xew.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,186 @@
|
||||
<!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-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=".svg,.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 style="font-size:10px;color:var(--ink-4);font-family:var(--font-terminal);margin-top:2px">
|
||||
Cliquer sur l'icône pour personnaliser · SVG JPG PNG WEBP · max 128×128 px
|
||||
</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>
|
||||
@@ -0,0 +1,55 @@
|
||||
// Échappe les valeurs serveur avant injection dans innerHTML
|
||||
function esc(s) {
|
||||
if (s == null) return '—';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
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`,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,134 @@
|
||||
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');
|
||||
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);
|
||||
}
|
||||
} 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.body.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; },
|
||||
};
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -0,0 +1,173 @@
|
||||
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 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 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:flex;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">
|
||||
<div class="g-row">
|
||||
<div class="g-ico" data-tip="CPU"><i class="fa-solid fa-microchip"></i></div>
|
||||
<div class="g-bar"><div class="g-fill ${offline ? '' : gFill(cpu ?? 0)}"
|
||||
style="width:${offline ? 0 : (cpu ?? 0).toFixed(0)}%"></div></div>
|
||||
<span class="g-val">${offline ? '—' : fmtPct(cpu)}</span>
|
||||
</div>
|
||||
<div class="g-row">
|
||||
<div class="g-ico" data-tip="RAM"><i class="fa-solid fa-memory"></i></div>
|
||||
<div class="g-bar"><div class="g-fill ${offline ? '' : gFill(memPct ?? 0)}"
|
||||
style="width:${offline ? 0 : (memPct ?? 0).toFixed(0)}%"></div></div>
|
||||
<span class="g-val">${offline ? '—' : (metrics?.memory_used && metrics?.memory_total ? fmt(metrics.memory_used) + '/' + fmt(metrics.memory_total) : '—')}</span>
|
||||
</div>
|
||||
<div class="g-row">
|
||||
<div class="g-ico" data-tip="Disque"><i class="fa-solid fa-hard-drive"></i></div>
|
||||
<div class="g-bar"><div class="g-fill ${offline ? '' : (diskPct >= (App.serverConfig?.warn_disk ?? 75) ? 'w' : '')}"
|
||||
style="width:${offline ? 0 : (diskPct ?? 0).toFixed(0)}%"></div></div>
|
||||
<span class="g-val">${offline ? '—' : fmtPct(diskPct)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div 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) {
|
||||
const entry = _agents.get(agentId);
|
||||
if (!entry) return;
|
||||
// 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: 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 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, getAgent, fmt, fmtPct };
|
||||
})();
|
||||
@@ -0,0 +1,440 @@
|
||||
const Popups = (() => {
|
||||
let _currentAgentId = null;
|
||||
let _agentCfgData = null;
|
||||
let _resizeObs = 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;
|
||||
await API.uploadIcon(agentId, file);
|
||||
img.src = API.iconUrl(agentId) + '?t=' + Date.now();
|
||||
};
|
||||
|
||||
// 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);
|
||||
|
||||
const smartBtn = metrics?.smart
|
||||
? `<div class="smart-btn ok" onclick="Popups.showSmart('${esc(agentId)}')" data-tip="Voir la santé complète du disque">
|
||||
<div class="smart-dot"></div>
|
||||
<span style="font-weight:600">SMART</span>
|
||||
<span>·</span>
|
||||
<span>${metrics.smart.passed ? 'PASSED' : 'FAILED'}</span>
|
||||
${metrics.smart.temperature ? `<span style="font-family:var(--font-mono);font-size:10px;color:var(--ink-3)"><i class="fa-solid fa-temperature-half"></i> ${metrics.smart.temperature}°C</span>` : ''}
|
||||
<i class="fa-solid fa-chevron-right" style="font-size:10px;color:var(--ink-4);margin-left:auto"></i>
|
||||
</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>
|
||||
<span class="chart-cur" style="color:var(--blue)">${Grid.fmtPct(metrics?.memory_used && metrics?.memory_total ? metrics.memory_used / metrics.memory_total * 100 : null)}</span>
|
||||
</div>
|
||||
<svg class="chart-svg" viewBox="0 0 200 52" preserveAspectRatio="none" id="det-mem-chart"></svg>
|
||||
<div class="chart-axis"><span>−30min</span><span>−15min</span><span>now</span></div>
|
||||
</div>
|
||||
</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>
|
||||
${smartBtn}
|
||||
</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(() => {
|
||||
API.putServerConfig({
|
||||
...App.serverConfig,
|
||||
popup_detail_w: pd.offsetWidth,
|
||||
popup_detail_h: pd.offsetHeight,
|
||||
}).catch(() => {});
|
||||
});
|
||||
_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>
|
||||
<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">
|
||||
<label style="font-size:0.85em;color:var(--fg2)">Commande curl — copiez et lancez en root sur la machine cible</label>
|
||||
<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="navigator.clipboard.writeText(document.getElementById('s-install-cmd').value).then(()=>{this.textContent='✓ Copié';setTimeout(()=>this.textContent='Copier',1500)})">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),
|
||||
};
|
||||
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';
|
||||
hideSrvCfg();
|
||||
}
|
||||
|
||||
// ══ POPUP SMART ══
|
||||
function showSmart(agentId) {
|
||||
const m = Grid.getAgent(agentId)?.metrics?.smart;
|
||||
if (!m) return;
|
||||
document.getElementById('smart-sub').textContent = agentId;
|
||||
const passColor = m.passed ? 'var(--ok)' : 'var(--err)';
|
||||
const passText = m.passed ? 'Disque en bonne santé' : 'Disque en mauvais état';
|
||||
const passSub = m.passed
|
||||
? 'Aucun problème détecté. Le disque fonctionne normalement.'
|
||||
: 'Des problèmes ont été détectés. Envisagez un remplacement.';
|
||||
|
||||
document.getElementById('smart-body').innerHTML = `
|
||||
<div class="smart-verdict" style="${m.passed ? '' : 'background:rgba(251,73,52,.1);border-color:rgba(251,73,52,.3)'}">
|
||||
<div style="font-size:28px;color:${passColor}"><i class="fa-solid ${m.passed ? 'fa-circle-check' : 'fa-circle-xmark'}"></i></div>
|
||||
<div><div style="font-size:16px;font-weight:700;color:${passColor}">${passText}</div>
|
||||
<div style="font-size:12px;color:var(--ink-3);margin-top:3px">${passSub}</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:var(--warn);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:rgba(77,187,38,.15);color:var(--ok)">Normale</span>
|
||||
</div>
|
||||
<div class="si-val">${m.temperature}<span class="u">°C</span></div>
|
||||
<div class="si-desc">Idéal : 20–50°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:${m.reallocated_sectors > 0 ? 'var(--err)' : 'var(--ok)'};font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-circle-check"></i></span>
|
||||
<span style="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">S'ils apparaissent en grand nombre, une panne est imminente.</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:var(--blue);font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-clock-rotate-left"></i></span>
|
||||
<span style="font-weight:600;font-size:12px;flex:1">Heures de fonctionnement</span>
|
||||
</div>
|
||||
<div class="si-val">${m.power_on_hours.toLocaleString('fr-FR')}<span class="u">h</span></div>
|
||||
<div class="si-desc">≈${Math.floor(m.power_on_hours/24)} jours. Un disque dure en moyenne 3 à 5 ans.</div>
|
||||
</div>` : ''}
|
||||
${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:var(--ok);font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-battery-full"></i></span>
|
||||
<span style="font-weight:600;font-size:12px;flex:1">Durée de vie SSD</span>
|
||||
</div>
|
||||
<div class="si-val">${m.wear_level}<span class="u">%</span></div>
|
||||
<div class="si-desc">100% = neuf · 0% = fin de vie recommandée.</div>
|
||||
</div>` : ''}
|
||||
</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);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
showDetail, hideDetail,
|
||||
showAgentCfg, sendAgentConfig, toggleCbox,
|
||||
showSrvCfg, hideSrvCfg, saveSrvCfg, confirmDeleteAgent, doDeleteAgent,
|
||||
showSmart,
|
||||
};
|
||||
})();
|
||||
@@ -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; }
|
||||
@@ -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}
|
||||
@@ -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; }
|
||||
@@ -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}
|
||||
@@ -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); }
|
||||
@@ -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; }
|
||||
@@ -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}
|
||||
@@ -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"); }
|
||||
@@ -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")}
|
||||
@@ -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
|
||||
@@ -0,0 +1,188 @@
|
||||
#!/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é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"
|
||||
|
||||
# ── 2. 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"
|
||||
|
||||
# ── 3. 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))"
|
||||
|
||||
# ── 4. 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"
|
||||
|
||||
# ── 5. Installer le binaire ────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "[1/5] Installation du binaire dans /usr/local/bin/"
|
||||
|
||||
# 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é"
|
||||
|
||||
# ── 6. 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éé"
|
||||
|
||||
# ── 7. Écrire config.toml ─────────────────────────────────────────────────────
|
||||
echo "[3/5] Écriture de $CONFIG_FILE"
|
||||
|
||||
# Ne pas écraser une config existante (upgrade)
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
warn "config.toml déjà présent — conservé tel quel"
|
||||
else
|
||||
cat > "$CONFIG_FILE" << TOML
|
||||
[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 créé"
|
||||
fi
|
||||
|
||||
# ── 8. 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é"
|
||||
|
||||
# ── 9. 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 ""
|
||||
ok "Installation terminée — agent $TAG opérationnel"
|
||||
echo " Config : $CONFIG_FILE"
|
||||
echo " Logs : journalctl -u nanometrics-agent -f"
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/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"
|
||||
|
||||
# ── 1. Compiler 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"
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM golang:1.22-alpine 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 alpine:3.19
|
||||
RUN apk add --no-cache ca-certificates
|
||||
WORKDIR /app
|
||||
COPY --from=builder /app/nanometrics-server .
|
||||
VOLUME /data
|
||||
EXPOSE 8080 9999/udp
|
||||
CMD ["./nanometrics-server"]
|
||||
@@ -0,0 +1,7 @@
|
||||
# Dockerfile de dev : utilise le binaire pré-compilé localement (pas de pull Docker Hub)
|
||||
FROM nginx:alpine
|
||||
COPY nanometrics-server /app/nanometrics-server
|
||||
WORKDIR /app
|
||||
VOLUME /data
|
||||
EXPOSE 8080 9999/udp
|
||||
CMD ["./nanometrics-server"]
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
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 pour les colonnes ajoutées après la création initiale
|
||||
_, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN version TEXT NOT NULL DEFAULT ''`)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DB) Close() { _ = d.conn.Close() }
|
||||
|
||||
func (d *DB) UpsertAgent(m *models.AgentMetrics) error {
|
||||
ts := time.Now().Unix()
|
||||
_, err := d.conn.Exec(`
|
||||
INSERT INTO agents (id, hostname, ip, status, last_seen, version)
|
||||
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`,
|
||||
m.Hostname, m.Hostname, m.IP, m.Status, ts, m.Version)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) InsertMetrics(m *models.AgentMetrics) error {
|
||||
ts := time.Now().Unix()
|
||||
var smartPassed, smartTemp, smartRealloc, smartHours, smartWear interface{}
|
||||
if m.Smart != nil {
|
||||
b := 0
|
||||
if m.Smart.Passed {
|
||||
b = 1
|
||||
}
|
||||
smartPassed = b
|
||||
smartTemp = m.Smart.Temperature
|
||||
smartRealloc = m.Smart.ReallocatedSectors
|
||||
smartHours = m.Smart.PowerOnHours
|
||||
smartWear = m.Smart.WearLevel
|
||||
}
|
||||
_, 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_passed, smart_temp, smart_realloc, smart_hours, smart_wear)
|
||||
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,
|
||||
smartPassed, smartTemp, smartRealloc, smartHours, smartWear)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) GetAgents() ([]models.Agent, error) {
|
||||
rows, err := d.conn.Query(`SELECT id, hostname, ip, status, last_seen, version FROM agents`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var agents []models.Agent
|
||||
for rows.Next() {
|
||||
var a models.Agent
|
||||
if err := rows.Scan(&a.ID, &a.Hostname, &a.IP, &a.Status, &a.LastSeen, &a.Version); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agents = append(agents, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return agents, 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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: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:
|
||||
@@ -0,0 +1,29 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
server:
|
||||
build: .
|
||||
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:alpine
|
||||
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
|
||||
|
||||
volumes:
|
||||
nanometrics_data:
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -0,0 +1,37 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/user/nanometrics/server/db"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(agents)
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteAgentHandler(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]
|
||||
if err := database.DeleteAgent(agentID); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/user/nanometrics/server/db"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
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)(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
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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"`
|
||||
}
|
||||
|
||||
type SmartMetrics struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||