Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1e1aa40d8 | |||
| 7fb47ffde8 | |||
| 3c15943e2e | |||
| a9506a5505 | |||
| ee5e8710a3 | |||
| d715b452c1 | |||
| fdeb4c2088 | |||
| 66605e22e3 | |||
| 1250cd7d3c | |||
| dc60fe2a8d | |||
| 55e68189d3 | |||
| db6fc65ee1 | |||
| 1002a6be68 | |||
| 017d7bb1bb | |||
| 5ee8b66464 | |||
| c238e9f2b8 | |||
| d7fe0004ad | |||
| 0247cfaada | |||
| dcfba242d6 | |||
| ff6cf1cd5e | |||
| 0430c0f2a8 | |||
| 49626ddb9e | |||
| f93f5741da | |||
| 982483e0bf | |||
| a53923fd8e | |||
| 1b9daae08a | |||
| fdf76477e5 | |||
| 22b429f247 | |||
| a2060a1713 | |||
| e65770407c | |||
| 9e77d961f5 | |||
| 3933301cff | |||
| 9f87c9294d | |||
| 638d347bb0 | |||
| 8f3dbd0532 | |||
| 99bdf79a63 | |||
| a22d1f4cd2 | |||
| d8f395cb53 | |||
| f69c22039b | |||
| 2bda420728 | |||
| f604e22f6e | |||
| 8d4dc0e853 | |||
| 311bdbc66d | |||
| 0df716b8b0 | |||
| 243c97d71b | |||
| e0ed96309c | |||
| 93747e4a04 | |||
| b93b55d5a8 | |||
| 46209b2965 | |||
| 775d54f07c | |||
| e9524858f5 | |||
| 1a1202abcf | |||
| c526a6e5ca |
@@ -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"
|
||||
@@ -10,4 +10,10 @@ server/*.test
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
config.toml
|
||||
# Config locaux (contiennent des IPs/secrets)
|
||||
agent/config.toml
|
||||
server/config.toml
|
||||
|
||||
# Sauf les configs cargo (pas de secrets)
|
||||
!.cargo/config.toml
|
||||
!agent/.cargo/config.toml
|
||||
|
||||
@@ -410,7 +410,7 @@ body { background:var(--bg-1); color:var(--ink-1); font-family:var(--font-ui); f
|
||||
<div class="chk-box" id="chk-udp"><i class="fa-solid fa-check"></i></div>
|
||||
<div class="chk-label">
|
||||
<div class="chk-name">UDP <span class="chk-badge udp">UDP</span></div>
|
||||
<div class="chk-desc">Fire-and-forget · serveur 10.0.0.50 · port 9999</div>
|
||||
<div class="chk-desc">Fire-and-forget · serveur 10.0.0.82 · port 9999</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -542,7 +542,7 @@ body{background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-s
|
||||
<div class="cfg-body">
|
||||
<div class="cfg-section">
|
||||
<div class="cfg-sec-title">PROTOCOLES DE TRANSPORT</div>
|
||||
<div class="check-row active"><div class="chk-box"><i class="fa-solid fa-check"></i></div><div class="chk-label"><div class="chk-name">UDP <span class="chk-badge udp">UDP</span></div><div class="chk-desc">Fire-and-forget · serveur 10.0.0.50 · port 9999</div></div></div>
|
||||
<div class="check-row active"><div class="chk-box"><i class="fa-solid fa-check"></i></div><div class="chk-label"><div class="chk-name">UDP <span class="chk-badge udp">UDP</span></div><div class="chk-desc">Fire-and-forget · serveur 10.0.0.82 · port 9999</div></div></div>
|
||||
<div class="check-row mqtt-active"><div class="chk-box" style="background:var(--purple);border-color:var(--purple);color:var(--bg-0)"><i class="fa-solid fa-check"></i></div><div class="chk-label"><div class="chk-name">MQTT <span class="chk-badge mqtt">MQTT</span></div><div class="chk-desc">Bidirectionnel · broker 10.0.0.3 · port 1883</div></div></div>
|
||||
<div class="mqtt-opts">
|
||||
<div class="mqtt-field"><label>Broker</label><input class="mqtt-input" type="text" value="10.0.0.3"></div>
|
||||
|
||||
@@ -485,7 +485,7 @@ body{background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-s
|
||||
<!-- PROTOCOLES -->
|
||||
<div class="cfg-section">
|
||||
<div class="cfg-sec-title">PROTOCOLES DE TRANSPORT</div>
|
||||
<div class="check-row active"><div class="chk-box"><i class="fa-solid fa-check"></i></div><div class="chk-label"><div class="chk-name">UDP <span class="chk-badge udp">UDP</span></div><div class="chk-desc">Fire-and-forget · 10.0.0.50:9999</div></div></div>
|
||||
<div class="check-row active"><div class="chk-box"><i class="fa-solid fa-check"></i></div><div class="chk-label"><div class="chk-name">UDP <span class="chk-badge udp">UDP</span></div><div class="chk-desc">Fire-and-forget · 10.0.0.82:9999</div></div></div>
|
||||
<div class="check-row mqtt-active"><div class="chk-box" style="background:var(--purple);border-color:var(--purple);color:var(--bg-0)"><i class="fa-solid fa-check"></i></div><div class="chk-label"><div class="chk-name">MQTT <span class="chk-badge mqtt">MQTT</span></div><div class="chk-desc">Bidirectionnel · 10.0.0.3:1883</div></div></div>
|
||||
<div class="mqtt-opts">
|
||||
<div class="mqtt-field"><label>Broker</label><input class="mqtt-input" type="text" value="10.0.0.3"></div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{"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"}
|
||||
{"type":"server-started","port":55731,"host":"0.0.0.0","url_host":"10.0.0.82","url":"http://10.0.0.82: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"}
|
||||
{"type":"screen-added","file":"/home/gilles/projects/nano_metrics/.superpowers/brainstorm/599687-1779425985/content/approaches.html"}
|
||||
{"source":"user-event","type":"click","text":"C\n \n SQLite + WebSocket ⭐ Recommandé\n SQLite — simplicité opérationnelle, suffisant pour 20+ agents avec rétention configurable.\n WebSocket — bidirectionnel dès le départ, sans surcoût opérationnel.\n \n AvantagesPas de conteneur DB supplémentaireWebSocket prêt pour extensions futuresSimple à debugger et sauvegarder\n LimitesRequêtes temporelles moins expressives qu'InfluxDBScalabilité limitée au-delà de ~100 agents","choice":"c","id":null,"timestamp":1779426035162}
|
||||
{"source":"user-event","type":"click","text":"C\n \n SQLite + WebSocket ⭐ Recommandé\n SQLite — simplicité opérationnelle, suffisant pour 20+ agents avec rétention configurable.\n WebSocket — bidirectionnel dès le départ, sans surcoût opérationnel.\n \n AvantagesPas de conteneur DB supplémentaireWebSocket prêt pour extensions futuresSimple à debugger et sauvegarder\n LimitesRequêtes temporelles moins expressives qu'InfluxDBScalabilité limitée au-delà de ~100 agents","choice":"c","id":null,"timestamp":1779426056446}
|
||||
|
||||
@@ -14,7 +14,7 @@ Ligne de Conduite 1 : L'Agent de Télémétrie (Rust)
|
||||
|
||||
Orchestration Temporelle : N'inclus aucun moteur asynchrone (comme Tokio). Les fréquences d'actualisation différenciées (ex: CPU toutes les 2s, Disque toutes les 60s) doivent être gérées via une boucle mono-thread utilisant des pauses natives std::thread::sleep pour suspendre complètement le processus.
|
||||
|
||||
Configuration : Implémente la lecture d'un fichier config.toml externe via la bibliothèque serde pour paramétrer dynamiquement l'adresse IP du serveur cible (10.0.0.50) et les métriques à activer.
|
||||
Configuration : Implémente la lecture d'un fichier config.toml externe via la bibliothèque serde pour paramétrer dynamiquement l'adresse IP du serveur cible (10.0.0.82) et les métriques à activer.
|
||||
|
||||
Transport : Utilise le protocole UDP pour expédier les charges utiles (payloads) en JSON, privilégiant la vitesse sans état (modèle fire-and-forget) sur un réseau local.
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ Créer `/etc/nanometrics/config.toml` :
|
||||
|
||||
```toml
|
||||
[server]
|
||||
ip = "10.0.0.50" # IP du serveur Go
|
||||
ip = "10.0.0.82" # IP du serveur Go
|
||||
port = 9999 # Port UDP du serveur
|
||||
|
||||
[mqtt]
|
||||
|
||||
@@ -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.17"
|
||||
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.17"
|
||||
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 @@
|
||||
[server]
|
||||
ip = "10.0.0.50"
|
||||
ip = "10.0.0.82"
|
||||
port = 9999
|
||||
|
||||
[protocols.udp]
|
||||
|
||||
@@ -13,8 +13,12 @@ pub struct Config {
|
||||
pub struct ServerConfig {
|
||||
pub ip: String,
|
||||
pub port: u16,
|
||||
#[serde(default = "default_iperf3_port")]
|
||||
pub iperf3_port: u16,
|
||||
}
|
||||
|
||||
fn default_iperf3_port() -> u16 { 5201 }
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Default)]
|
||||
pub struct ProtocolsConfig {
|
||||
#[serde(default)]
|
||||
@@ -90,6 +94,26 @@ pub struct MetricsConfig {
|
||||
pub temperature: MetricProto,
|
||||
#[serde(default)]
|
||||
pub smart: MetricProto,
|
||||
#[serde(default)]
|
||||
pub network_info: SlowMetricProto,
|
||||
#[serde(default)]
|
||||
pub hardware_info: SlowMetricProto,
|
||||
#[serde(default = "default_slow_time")]
|
||||
pub slow_daily_time: String,
|
||||
}
|
||||
|
||||
fn default_slow_time() -> String { "03:00".to_string() }
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct SlowMetricProto {
|
||||
#[serde(default = "default_true")]
|
||||
pub udp: bool,
|
||||
#[serde(default)]
|
||||
pub mqtt: bool,
|
||||
}
|
||||
|
||||
impl Default for SlowMetricProto {
|
||||
fn default() -> Self { Self { udp: true, mqtt: false } }
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone, Default)]
|
||||
|
||||
@@ -1,18 +1,75 @@
|
||||
use nanometrics_agent::{config, metrics, payload, transport};
|
||||
use sysinfo::{Components, Disks, Networks, System};
|
||||
use sysinfo::{Components, Networks, System};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::sync::mpsc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
static RUNNING: AtomicBool = AtomicBool::new(true);
|
||||
|
||||
extern "C" fn handle_signal(_: libc::c_int) {
|
||||
RUNNING.store(false, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn physical_ipv4_addrs() -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
unsafe {
|
||||
let mut ifap = std::ptr::null_mut::<libc::ifaddrs>();
|
||||
if libc::getifaddrs(&mut ifap) != 0 { return result; }
|
||||
let mut ifa = ifap;
|
||||
while !ifa.is_null() {
|
||||
let flags = (*ifa).ifa_flags as i32;
|
||||
let up = flags & libc::IFF_UP as i32 != 0;
|
||||
let loopback = flags & libc::IFF_LOOPBACK as i32 != 0;
|
||||
let pointop = flags & libc::IFF_POINTOPOINT as i32 != 0;
|
||||
if !up || loopback || pointop { ifa = (*ifa).ifa_next; continue; }
|
||||
|
||||
let name = std::ffi::CStr::from_ptr((*ifa).ifa_name)
|
||||
.to_string_lossy().into_owned();
|
||||
|
||||
// Type 1 = ARPHRD_ETHER (Ethernet + WiFi), exclut WireGuard (65534), tunnels, etc.
|
||||
let itype: u32 = std::fs::read_to_string(
|
||||
format!("/sys/class/net/{}/type", name))
|
||||
.ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0);
|
||||
if itype != 1 { ifa = (*ifa).ifa_next; continue; }
|
||||
|
||||
// Exclut bridges et interfaces Docker par nom
|
||||
let is_virtual = name.starts_with("br-") || name.starts_with("docker")
|
||||
|| name.starts_with("virbr") || name.starts_with("veth");
|
||||
if is_virtual { ifa = (*ifa).ifa_next; continue; }
|
||||
|
||||
if let Some(addr) = (*ifa).ifa_addr.as_ref() {
|
||||
if addr.sa_family as i32 == libc::AF_INET {
|
||||
let sin = addr as *const _ as *const libc::sockaddr_in;
|
||||
let b = (*sin).sin_addr.s_addr.to_ne_bytes();
|
||||
result.push(format!("{}.{}.{}.{}", b[0], b[1], b[2], b[3]));
|
||||
}
|
||||
}
|
||||
ifa = (*ifa).ifa_next;
|
||||
}
|
||||
libc::freeifaddrs(ifap);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn get_local_ip(server_ip: &str) -> String {
|
||||
let physical = physical_ipv4_addrs();
|
||||
|
||||
fn get_local_ip() -> String {
|
||||
use std::net::UdpSocket;
|
||||
if let Ok(s) = UdpSocket::bind("0.0.0.0:0") {
|
||||
if s.connect("8.8.8.8:80").is_ok() {
|
||||
if let Ok(addr) = s.local_addr() {
|
||||
return addr.ip().to_string();
|
||||
for target in &[format!("{}:80", server_ip), "8.8.8.8:80".to_string()] {
|
||||
if let Ok(s) = UdpSocket::bind("0.0.0.0:0") {
|
||||
if s.connect(target.as_str()).is_ok() {
|
||||
if let Ok(addr) = s.local_addr() {
|
||||
let ip = addr.ip().to_string();
|
||||
// N'accepte que si c'est une vraie interface physique
|
||||
if ip != "0.0.0.0" && physical.contains(&ip) {
|
||||
return ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"0.0.0.0".to_string()
|
||||
// Fallback : première IP physique disponible
|
||||
physical.into_iter().next().unwrap_or_else(|| "0.0.0.0".to_string())
|
||||
}
|
||||
|
||||
fn apply_config_update(cfg: &mut config::Config, data: &[u8]) {
|
||||
@@ -30,11 +87,10 @@ fn main() {
|
||||
.expect("Impossible de charger config.toml");
|
||||
|
||||
let hostname = System::host_name().unwrap_or_else(|| "unknown".to_string());
|
||||
let ip = get_local_ip();
|
||||
let ip = get_local_ip(&cfg.server.ip);
|
||||
|
||||
let mut sys = System::new();
|
||||
let mut networks = Networks::new_with_refreshed_list();
|
||||
let mut disks = Disks::new_with_refreshed_list();
|
||||
let mut components = Components::new_with_refreshed_list();
|
||||
|
||||
let udp_sender = if cfg.protocols.udp.enabled {
|
||||
@@ -50,12 +106,38 @@ fn main() {
|
||||
None
|
||||
};
|
||||
|
||||
unsafe {
|
||||
libc::signal(libc::SIGTERM, handle_signal as libc::sighandler_t);
|
||||
libc::signal(libc::SIGINT, handle_signal as libc::sighandler_t);
|
||||
}
|
||||
|
||||
let mut last_slow = Instant::now();
|
||||
let mut last_medium = Instant::now();
|
||||
let mut first_medium = true;
|
||||
let mut first_slow = true;
|
||||
|
||||
loop {
|
||||
// Scheduler métriques lentes (startup + 1×/jour à l'heure configurée)
|
||||
let slow_time: (u32, u32) = {
|
||||
let parts: Vec<&str> = cfg.metrics.slow_daily_time.splitn(2, ':').collect();
|
||||
let h = parts.first().and_then(|s| s.parse().ok()).unwrap_or(3u32);
|
||||
let m = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0u32);
|
||||
(h, m)
|
||||
};
|
||||
let mut slow_daily_done = false;
|
||||
let mut slow_last_yday = metrics::network_info::current_yday().wrapping_sub(1);
|
||||
|
||||
// Collecte immédiate au démarrage
|
||||
let mut startup_net: Option<Vec<payload::NetworkInterface>> = None;
|
||||
let mut startup_hw: Option<payload::HardwareInfo> = None;
|
||||
if cfg.metrics.network_info.udp || cfg.metrics.network_info.mqtt {
|
||||
let ni = metrics::network_info::collect(&cfg.server.ip, cfg.server.iperf3_port);
|
||||
if !ni.is_empty() { startup_net = Some(ni); }
|
||||
}
|
||||
if cfg.metrics.hardware_info.udp || cfg.metrics.hardware_info.mqtt {
|
||||
startup_hw = metrics::hardware::collect();
|
||||
}
|
||||
|
||||
while RUNNING.load(Ordering::Relaxed) {
|
||||
let now = Instant::now();
|
||||
|
||||
while let Ok(transport::mqtt::MqttIncoming::ConfigUpdate(data)) = incoming_rx.try_recv() {
|
||||
@@ -65,10 +147,35 @@ fn main() {
|
||||
sys.refresh_cpu_usage();
|
||||
sys.refresh_memory();
|
||||
|
||||
// Métriques lentes quotidiennes
|
||||
let cur_yday = metrics::network_info::current_yday();
|
||||
if cur_yday != slow_last_yday {
|
||||
slow_last_yday = cur_yday;
|
||||
slow_daily_done = false;
|
||||
}
|
||||
let mut daily_net: Option<Vec<payload::NetworkInterface>> = None;
|
||||
let mut daily_hw: Option<payload::HardwareInfo> = None;
|
||||
if !slow_daily_done {
|
||||
let (ch, cm) = metrics::network_info::current_hhmm();
|
||||
if ch == slow_time.0 && cm == slow_time.1 {
|
||||
slow_daily_done = true;
|
||||
if cfg.metrics.network_info.udp || cfg.metrics.network_info.mqtt {
|
||||
let ni = metrics::network_info::collect(&cfg.server.ip, cfg.server.iperf3_port);
|
||||
if !ni.is_empty() { daily_net = Some(ni); }
|
||||
}
|
||||
if cfg.metrics.hardware_info.udp || cfg.metrics.hardware_info.mqtt {
|
||||
daily_hw = metrics::hardware::collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut m = payload::AgentMetrics {
|
||||
hostname: hostname.clone(),
|
||||
ip: ip.clone(),
|
||||
status: "online".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
network_info: daily_net.or_else(|| startup_net.take()),
|
||||
hardware_info: daily_hw.or_else(|| startup_hw.take()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -102,9 +209,8 @@ fn main() {
|
||||
}
|
||||
|
||||
if first_slow || now.duration_since(last_slow).as_secs() >= 60 {
|
||||
disks.refresh();
|
||||
if cfg.metrics.disk.udp || cfg.metrics.disk.mqtt {
|
||||
let (used, free, total) = metrics::disk::get(&disks);
|
||||
let (used, free, total) = metrics::disk::get();
|
||||
m.hdd_used = Some(used);
|
||||
m.hdd_free = Some(free);
|
||||
m.hdd_total = Some(total);
|
||||
@@ -135,4 +241,24 @@ fn main() {
|
||||
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
|
||||
// Déconnexion propre : notifier le serveur avant de quitter
|
||||
let offline = serde_json::to_string(&payload::AgentMetrics {
|
||||
hostname: hostname.clone(),
|
||||
ip: ip.clone(),
|
||||
status: "offline".to_string(),
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
..Default::default()
|
||||
}).unwrap_or_default();
|
||||
|
||||
if let Some(ref udp) = udp_sender {
|
||||
udp.send(&offline);
|
||||
}
|
||||
if let Some(ref client) = mqtt_client {
|
||||
transport::mqtt::publish_status(
|
||||
client, &cfg.protocols.mqtt.topic_base, &hostname, "offline",
|
||||
);
|
||||
std::thread::sleep(Duration::from_millis(200)); // laisser le temps au broker de recevoir
|
||||
let _ = client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
fn run_dmidecode(type_num: u8) -> String {
|
||||
std::process::Command::new("dmidecode")
|
||||
.args(["-t", &type_num.to_string()])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn extract_field(text: &str, key: &str) -> Option<String> {
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.starts_with(key) {
|
||||
let val = t[key.len()..].trim().trim_start_matches(':').trim();
|
||||
if !val.is_empty() && val != "Not Specified" && val != "Unknown"
|
||||
&& val != "To Be Filled By O.E.M." {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn is_available() -> bool {
|
||||
std::process::Command::new("which")
|
||||
.arg("dmidecode")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn collect() -> Option<crate::payload::HardwareInfo> {
|
||||
if !is_available() { return None; }
|
||||
|
||||
let board = run_dmidecode(2); // Baseboard
|
||||
let cpu = run_dmidecode(4); // Processor
|
||||
let mem = run_dmidecode(17); // Memory Device
|
||||
|
||||
let mut slots_total: i64 = 0;
|
||||
let mut slots_used: i64 = 0;
|
||||
let mut ram_type: Option<String> = None;
|
||||
let mut ram_speed: Option<i64> = None;
|
||||
|
||||
for block in mem.split("\n\n") {
|
||||
if !block.contains("Memory Device") { continue; }
|
||||
slots_total += 1;
|
||||
if let Some(size) = extract_field(block, "Size") {
|
||||
if !size.contains("No Module") && size != "0" {
|
||||
slots_used += 1;
|
||||
}
|
||||
}
|
||||
if ram_type.is_none() {
|
||||
ram_type = extract_field(block, "Type")
|
||||
.filter(|t| t != "Unknown" && t != "Other");
|
||||
}
|
||||
if ram_speed.is_none() {
|
||||
if let Some(spd) = extract_field(block, "Speed") {
|
||||
ram_speed = spd.split_whitespace().next()
|
||||
.and_then(|s| s.parse().ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(crate::payload::HardwareInfo {
|
||||
motherboard_vendor: extract_field(&board, "Manufacturer"),
|
||||
motherboard_model: extract_field(&board, "Product Name"),
|
||||
cpu_model: extract_field(&cpu, "Version").or_else(|| extract_field(&cpu, "Family")),
|
||||
ram_type,
|
||||
ram_speed_mhz: ram_speed,
|
||||
ram_slots_used: if slots_total > 0 { Some(slots_used) } else { None },
|
||||
ram_slots_total: if slots_total > 0 { Some(slots_total) } else { None },
|
||||
})
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
pub mod cpu;
|
||||
pub mod disk;
|
||||
pub mod hardware;
|
||||
pub mod memory;
|
||||
pub mod network;
|
||||
pub mod network_info;
|
||||
pub mod smart;
|
||||
pub mod temperature;
|
||||
pub mod uptime;
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
use std::mem::MaybeUninit;
|
||||
|
||||
fn local_hhmm() -> (u32, u32) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let mut tm = MaybeUninit::<libc::tm>::uninit();
|
||||
unsafe { libc::localtime_r(&now, tm.as_mut_ptr()) };
|
||||
let tm = unsafe { tm.assume_init() };
|
||||
(tm.tm_hour as u32, tm.tm_min as u32)
|
||||
}
|
||||
|
||||
pub fn current_hhmm() -> (u32, u32) {
|
||||
local_hhmm()
|
||||
}
|
||||
|
||||
pub fn current_yday() -> u32 {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let mut tm = MaybeUninit::<libc::tm>::uninit();
|
||||
unsafe { libc::localtime_r(&now, tm.as_mut_ptr()) };
|
||||
unsafe { tm.assume_init() }.tm_yday as u32
|
||||
}
|
||||
|
||||
fn is_physical(name: &str) -> bool {
|
||||
// Type 1 = ARPHRD_ETHER (Ethernet + WiFi). WireGuard = 65534, tunnels = autres.
|
||||
let itype: u32 = std::fs::read_to_string(format!("/sys/class/net/{}/type", name))
|
||||
.ok().and_then(|s| s.trim().parse().ok()).unwrap_or(0);
|
||||
if itype != 1 { return false; }
|
||||
// Exclut bridges et interfaces Docker par nom (type 1 aussi)
|
||||
!name.starts_with("br-") && !name.starts_with("docker")
|
||||
&& !name.starts_with("virbr") && !name.starts_with("veth")
|
||||
}
|
||||
|
||||
fn read_sysfs(iface: &str, file: &str) -> Option<String> {
|
||||
std::fs::read_to_string(format!("/sys/class/net/{}/{}", iface, file))
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
fn is_wifi(name: &str) -> bool {
|
||||
std::path::Path::new(&format!("/sys/class/net/{}/wireless", name)).exists()
|
||||
}
|
||||
|
||||
fn wol_status(name: &str) -> Option<bool> {
|
||||
let out = std::process::Command::new("ethtool").arg(name).output().ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.starts_with("Wake-on:") {
|
||||
let val = t.split(':').nth(1)?.trim().to_string();
|
||||
return Some(val != "d" && !val.is_empty());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn iperf_mbps(server_ip: &str, port: u16) -> Option<f64> {
|
||||
std::process::Command::new("which").arg("iperf3")
|
||||
.output().ok()
|
||||
.filter(|o| o.status.success())?;
|
||||
let port_str = port.to_string();
|
||||
let out = std::process::Command::new("iperf3")
|
||||
.args(["-c", server_ip, "-p", &port_str, "-J", "-t", "5", "-P", "1"])
|
||||
.output().ok()?;
|
||||
let json = String::from_utf8_lossy(&out.stdout);
|
||||
let v: serde_json::Value = serde_json::from_str(&json).ok()?;
|
||||
let bps = v["end"]["sum_received"]["bits_per_second"].as_f64()?;
|
||||
Some((bps / 1_000_000.0 * 10.0).round() / 10.0)
|
||||
}
|
||||
|
||||
pub fn collect(server_ip: &str, iperf3_port: u16) -> Vec<crate::payload::NetworkInterface> {
|
||||
let entries = match std::fs::read_dir("/sys/class/net") {
|
||||
Ok(e) => e,
|
||||
Err(_) => return vec![],
|
||||
};
|
||||
let mut ifaces: Vec<String> = entries
|
||||
.flatten()
|
||||
.map(|e| e.file_name().into_string().unwrap_or_default())
|
||||
.filter(|n| is_physical(n))
|
||||
.collect();
|
||||
ifaces.sort();
|
||||
if ifaces.is_empty() { return vec![]; }
|
||||
|
||||
let iperf = iperf_mbps(server_ip, iperf3_port);
|
||||
|
||||
ifaces.iter().map(|name| {
|
||||
let speed = read_sysfs(name, "speed")
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.filter(|&v| v > 0);
|
||||
let mac = read_sysfs(name, "address").unwrap_or_default();
|
||||
let wifi = is_wifi(name);
|
||||
crate::payload::NetworkInterface {
|
||||
name: name.clone(),
|
||||
if_type: if wifi { "wifi".to_string() } else { "ethernet".to_string() },
|
||||
speed_mbps: speed,
|
||||
mac,
|
||||
wol: if wifi { None } else { wol_status(name) },
|
||||
iperf_mbps: iperf,
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
@@ -1,19 +1,25 @@
|
||||
use serde::Deserialize;
|
||||
use crate::payload::SmartMetrics;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SmartJson {
|
||||
smart_status: SmartStatus,
|
||||
#[serde(default)]
|
||||
smart_status: Option<SmartStatus>,
|
||||
temperature: Option<SmartTemp>,
|
||||
ata_smart_attributes: Option<SmartAttrs>,
|
||||
nvme_smart_health_information_log: Option<NvmeHealth>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SmartStatus { passed: bool }
|
||||
struct SmartStatus {
|
||||
#[serde(default)]
|
||||
passed: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SmartTemp { current: i64 }
|
||||
struct SmartTemp {
|
||||
#[serde(default)]
|
||||
current: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SmartAttrs { table: Vec<SmartAttr> }
|
||||
@@ -42,10 +48,10 @@ pub fn is_available() -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn parse_json(json: &str) -> Result<SmartMetrics, serde_json::Error> {
|
||||
pub fn parse_json(json: &str) -> Result<crate::payload::SmartMetrics, serde_json::Error> {
|
||||
let s: SmartJson = serde_json::from_str(json)?;
|
||||
|
||||
let temperature = s.temperature.as_ref().map(|t| t.current)
|
||||
let temperature = s.temperature.as_ref().and_then(|t| t.current)
|
||||
.or_else(|| s.nvme_smart_health_information_log.as_ref()?.temperature);
|
||||
|
||||
let mut reallocated = None;
|
||||
@@ -71,8 +77,9 @@ pub fn parse_json(json: &str) -> Result<SmartMetrics, serde_json::Error> {
|
||||
}
|
||||
}
|
||||
|
||||
Ok(SmartMetrics {
|
||||
passed: s.smart_status.passed,
|
||||
Ok(crate::payload::SmartMetrics {
|
||||
device: String::new(),
|
||||
passed: s.smart_status.as_ref().map(|s| s.passed).unwrap_or(false),
|
||||
temperature,
|
||||
reallocated_sectors: reallocated,
|
||||
power_on_hours: power_hours,
|
||||
@@ -80,19 +87,57 @@ pub fn parse_json(json: &str) -> Result<SmartMetrics, serde_json::Error> {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn collect() -> Option<SmartMetrics> {
|
||||
pub fn collect() -> Option<Vec<crate::payload::SmartMetrics>> {
|
||||
if !is_available() {
|
||||
eprintln!("[smart] smartctl introuvable dans PATH");
|
||||
return None;
|
||||
}
|
||||
for dev in &["/dev/sda", "/dev/nvme0"] {
|
||||
let output = std::process::Command::new("smartctl")
|
||||
.args(["-j", dev])
|
||||
.output()
|
||||
.ok()?;
|
||||
let json = String::from_utf8_lossy(&output.stdout);
|
||||
if let Ok(metrics) = parse_json(&json) {
|
||||
return Some(metrics);
|
||||
let mut set = std::collections::HashSet::new();
|
||||
|
||||
// SATA/SAS : /sys/block/sd* → /dev/sda, /dev/sdb…
|
||||
for e in std::fs::read_dir("/sys/block").into_iter().flatten().flatten() {
|
||||
let n = e.file_name().into_string().unwrap_or_default();
|
||||
if n.starts_with("sd") {
|
||||
set.insert(format!("/dev/{}", n));
|
||||
}
|
||||
}
|
||||
None
|
||||
|
||||
// NVMe : /sys/class/nvme/nvme* → /dev/nvme0, /dev/nvme1…
|
||||
// On utilise le contrôleur (char device), pas le namespace (block device),
|
||||
// car smartctl ne peut exécuter les commandes admin SMART que via le contrôleur.
|
||||
// La règle udev 99-nanometrics-smart.rules lui donne l'accès groupe disk.
|
||||
for e in std::fs::read_dir("/sys/class/nvme").into_iter().flatten().flatten() {
|
||||
let n = e.file_name().into_string().unwrap_or_default();
|
||||
if n.starts_with("nvme") {
|
||||
set.insert(format!("/dev/{}", n));
|
||||
}
|
||||
}
|
||||
|
||||
let mut devs: Vec<String> = set.into_iter().collect();
|
||||
devs.sort();
|
||||
eprintln!("[smart] disques détectés: {:?}", devs);
|
||||
|
||||
let mut results = Vec::new();
|
||||
for dev in &devs {
|
||||
let output = match std::process::Command::new("smartctl")
|
||||
.args(["-a", "-j", dev])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(e) => { eprintln!("[smart] erreur exec smartctl {}: {}", dev, e); continue }
|
||||
};
|
||||
let json = String::from_utf8_lossy(&output.stdout);
|
||||
match parse_json(&json) {
|
||||
Ok(metrics) => {
|
||||
results.push(crate::payload::SmartMetrics {
|
||||
device: dev.trim_start_matches("/dev/").to_string(),
|
||||
..metrics
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[smart] {} parse JSON échoué: {}", dev, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
if results.is_empty() { None } else { Some(results) }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ pub struct AgentMetrics {
|
||||
pub hostname: String,
|
||||
pub ip: String,
|
||||
pub status: String,
|
||||
#[serde(default)]
|
||||
pub version: String,
|
||||
pub cpu_percent: Option<f32>,
|
||||
pub memory_used: Option<u64>,
|
||||
pub memory_free: Option<u64>,
|
||||
@@ -16,11 +18,36 @@ pub struct AgentMetrics {
|
||||
pub network_rx: Option<u64>,
|
||||
pub network_tx: Option<u64>,
|
||||
pub temperature: Option<f32>,
|
||||
pub smart: Option<SmartMetrics>,
|
||||
pub smart: Option<Vec<SmartMetrics>>,
|
||||
pub network_info: Option<Vec<NetworkInterface>>,
|
||||
pub hardware_info: Option<HardwareInfo>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct NetworkInterface {
|
||||
pub name: String,
|
||||
pub if_type: String,
|
||||
pub speed_mbps: Option<i64>,
|
||||
pub mac: String,
|
||||
pub wol: Option<bool>,
|
||||
pub iperf_mbps: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct HardwareInfo {
|
||||
pub motherboard_vendor: Option<String>,
|
||||
pub motherboard_model: Option<String>,
|
||||
pub cpu_model: Option<String>,
|
||||
pub ram_type: Option<String>,
|
||||
pub ram_speed_mhz: Option<i64>,
|
||||
pub ram_slots_used: Option<i64>,
|
||||
pub ram_slots_total: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct SmartMetrics {
|
||||
#[serde(default)]
|
||||
pub device: String,
|
||||
pub passed: bool,
|
||||
pub temperature: Option<i64>,
|
||||
pub reallocated_sectors: Option<i64>,
|
||||
|
||||
@@ -78,3 +78,8 @@ pub fn publish_metrics(client: &Client, topic_base: &str, hostname: &str, json:
|
||||
let topic = format!("{}/{}/metrics", topic_base, hostname);
|
||||
let _ = client.publish(topic, QoS::AtMostOnce, false, json);
|
||||
}
|
||||
|
||||
pub fn publish_status(client: &Client, topic_base: &str, hostname: &str, status: &str) {
|
||||
let topic = format!("{}/{}/status", topic_base, hostname);
|
||||
let _ = client.publish(topic, QoS::AtLeastOnce, true, status);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ fn test_config_parse_complet() {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
write!(f, r#"
|
||||
[server]
|
||||
ip = "10.0.0.50"
|
||||
ip = "10.0.0.82"
|
||||
port = 9999
|
||||
|
||||
[protocols.udp]
|
||||
@@ -26,7 +26,7 @@ udp = true
|
||||
mqtt = false
|
||||
"#).unwrap();
|
||||
let cfg = nanometrics_agent::config::load(f.path()).unwrap();
|
||||
assert_eq!(cfg.server.ip, "10.0.0.50");
|
||||
assert_eq!(cfg.server.ip, "10.0.0.82");
|
||||
assert_eq!(cfg.server.port, 9999);
|
||||
assert!(cfg.protocols.udp.enabled);
|
||||
assert!(cfg.protocols.mqtt.enabled);
|
||||
@@ -40,7 +40,7 @@ fn test_config_mqtt_absent() {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
write!(f, r#"
|
||||
[server]
|
||||
ip = "10.0.0.50"
|
||||
ip = "10.0.0.82"
|
||||
port = 9999
|
||||
|
||||
[protocols.udp]
|
||||
|
||||
@@ -18,6 +18,7 @@ fn test_serialize_json_complet() {
|
||||
temperature: None,
|
||||
smart: None,
|
||||
status: "online".to_string(),
|
||||
version: "0.0.0".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&m).unwrap();
|
||||
assert!(json.contains("\"hostname\":\"srv-01\""));
|
||||
@@ -31,13 +32,14 @@ fn test_serialize_avec_smart() {
|
||||
let m = AgentMetrics {
|
||||
hostname: "srv-01".to_string(),
|
||||
ip: "10.0.0.11".to_string(),
|
||||
smart: Some(SmartMetrics {
|
||||
smart: Some(vec![SmartMetrics {
|
||||
device: "sda".to_string(),
|
||||
passed: true,
|
||||
temperature: Some(34),
|
||||
reallocated_sectors: Some(0),
|
||||
power_on_hours: Some(4213),
|
||||
wear_level: Some(98),
|
||||
}),
|
||||
}]),
|
||||
status: "online".to_string(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
- metric du reseau: se concentrer uniquement sur les cartes reseaux appartenant a mon reseau local, les item interressant c est nom de l interface, type 10/100/1000mb, eth ou wifi, wake on lan actif ? macaddress, resultat de mesure d'un iperf avec un serveur (le serveur sera installe dans le compose deja creer pour l app serveur, c es metric ne sont recuprer qu au demarrage de l agent puis une fois/jours et seront visible dans le popup de la tuile
|
||||
- metric hardware, revupere des info sur carte mere, type de ram, type de cpu ( via un dmidecode ou similaire) ces données seront lu un fois au demarrage de l agent puis une fois par jours
|
||||
- le script et l agent doit etre installable sur un proxmox, verifie si les metric seront bien ok ? surtout les diques durs
|
||||
- reglage de la taille des caractere valable sur toute l ui du frontend
|
||||
- les data seront accessible via api rest pour autre service ou verveur mcp
|
||||
- les parametre du fichier de config seront exporte vers le serveur , et via config de le tuile, pourront etre renvoyer vers l agent
|
||||
- lors du script d installation, affiche la version de l agent installe
|
||||
- dans le pop up la ram est affiche en % seulement, ajoute le metric en Go
|
||||
- verifie que le devellopement de l agent est modulaire et optimise
|
||||
- ajouter en metric le nom des 4 processus qui consomme le plus de ressource
|
||||
- pour l agent une option debug ( activable via l'interrface de config de la tuile permet de generer un log des metric recuperer)quels commande pour visualiser le metric ?
|
||||
- pouvoir relancer le service depuis ler serveur
|
||||
- le site https://github.com/nicolargo/glances peut tu faire une analyse approfondi des metric relevé, des techno utilisé et me dire les similitude et difference avec mon projet ( créer un fichier comparatif_glance.md ) et synthese finale tu pourrais proposer des amelioration de mon outils qui pourrait s'inspirer de cette app, => amelioration_brainstormind.md
|
||||
- lors de l'installation d'iperf3 j'ai ce message: Choisissez cette option si Iperf3 doit démarrer automatiquement en tant que démon, maintenant et au démarrage. │
|
||||
│ │
|
||||
│ Faut-il démarrer automatiquement Iperf3 en tant que démon ? │
|
||||
│ │
|
||||
│ <Oui> <Non> , peut on faire une installe silencieuse pour le script des agent en repondant non
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 51 KiB |
@@ -15,7 +15,13 @@
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('../fonts/jetbrains-mono.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('../fonts/jetbrains-mono-bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
@@ -115,9 +121,26 @@ body{background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-s
|
||||
.g-bar{flex:1;height:5px;border-radius:3px;background:var(--bg-1);overflow:hidden}
|
||||
.g-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);width:34px;text-align:right}
|
||||
.g-val{font-family:var(--font-mono);font-size:11px;color:var(--ink-2);
|
||||
min-width:34px;white-space:nowrap;flex-shrink:0;text-align:right}
|
||||
/* Jauge standard (BatteryGauge) */
|
||||
.gs-row{display:flex;flex-direction:column;gap:3px}
|
||||
.gs-header{display:flex;align-items:center;gap:6px}
|
||||
.gs-ico{width:14px;text-align:center;font-size:10px;color:var(--ink-3);flex-shrink:0;cursor:help}
|
||||
.gs-lbl{flex:1;font-family:var(--font-terminal);font-size:10px;color:var(--ink-3);letter-spacing:.04em}
|
||||
.gs-val{font-family:var(--font-mono);font-size:11px;color:var(--ink-2);white-space:nowrap;flex-shrink:0}
|
||||
.gs-bar{position:relative;height:9px;border-radius:3px;background:var(--bg-1);
|
||||
border:1px solid var(--border-1);overflow:hidden;box-shadow:inset 0 1px 2px rgba(0,0,0,.3)}
|
||||
.gs-fill{height:100%;border-radius:2px;background:var(--ok);transition:width .3s}
|
||||
.gs-fill.w{background:var(--warn)}.gs-fill.e{background:var(--err)}
|
||||
.gs-gloss{position:absolute;inset:0;background:linear-gradient(180deg,rgba(255,255,255,.12),transparent);pointer-events:none}
|
||||
.tile-foot{font-family:var(--font-terminal);font-size:10px;color:var(--ink-4);
|
||||
display:flex;align-items:center;gap:5px;user-select:none}
|
||||
display:flex;align-items:center;justify-content:space-between;user-select:none}
|
||||
.tile-foot-info{display:flex;align-items:center;gap:5px;min-width:0;overflow:hidden}
|
||||
.btn-del-agent{border:none;background:transparent;cursor:pointer;flex-shrink:0;
|
||||
color:var(--ink-5);font-size:11px;padding:2px 4px;border-radius:4px;
|
||||
line-height:1;transition:color .15s,background .15s;user-select:none}
|
||||
.btn-del-agent:hover{color:var(--err);background:color-mix(in srgb,var(--err) 12%,transparent)}
|
||||
|
||||
/* FOOTER */
|
||||
.footer{background:var(--bg-0);border-top:1px solid var(--border-2);height:26px;
|
||||
@@ -181,12 +204,16 @@ body{background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-s
|
||||
.chart-cur{font-family:var(--font-mono);font-size:16px;font-weight:700}
|
||||
.chart-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)}
|
||||
.chart-minmax{display:flex;justify-content:space-between;margin-top:3px;font-family:var(--font-mono);font-size:9px;color:var(--ink-4)}
|
||||
.smart-pill{display:inline-flex;align-items:center;gap:3px;padding:1px 7px;border-radius:999px;
|
||||
font-size:9px;font-family:var(--font-terminal);font-weight:700;border:1px solid;
|
||||
cursor:pointer;user-select:none;flex-shrink:0;
|
||||
transition:opacity .12s,transform .08s,box-shadow .12s}
|
||||
.smart-pill:hover{opacity:.82;transform:scale(1.06)}
|
||||
.smart-pill.ok{color:var(--ok);background:rgba(77,187,38,.12);border-color:rgba(77,187,38,.32)}
|
||||
.smart-pill.old{color:var(--warn);background:rgba(250,189,47,.12);border-color:rgba(250,189,47,.32)}
|
||||
.smart-pill.prefail{color:var(--accent);background:var(--accent-tint);border-color:rgba(254,128,25,.32)}
|
||||
.smart-pill.hs{color:var(--err);background:rgba(251,73,52,.12);border-color:rgba(251,73,52,.32)}
|
||||
.meta-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
||||
.meta{background:var(--bg-3);border-radius:6px;padding:8px 10px;border:1px solid var(--border-1)}
|
||||
.meta-lbl{font-size:9px;color:var(--ink-4);font-family:var(--font-terminal);letter-spacing:.06em}
|
||||
@@ -258,5 +285,12 @@ body{background:var(--bg-1);color:var(--ink-1);font-family:var(--font-ui);font-s
|
||||
border-radius:6px;background:var(--bg-3);border:1px solid var(--border-1)}
|
||||
.attr-ok{color:var(--ok)}
|
||||
|
||||
/* Réseau + Hardware */
|
||||
.net-table{display:flex;flex-direction:column;gap:3px}
|
||||
.net-row{display:grid;grid-template-columns:18px 1fr 56px 130px 90px 90px;
|
||||
align-items:center;gap:8px;padding:6px 10px;
|
||||
background:var(--bg-3);border-radius:6px;border:1px solid var(--border-1);
|
||||
font-family:var(--font-terminal);font-size:10px;color:var(--ink-2)}
|
||||
|
||||
::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:var(--bg-1)}
|
||||
::-webkit-scrollbar-thumb{background:var(--bg-4);border-radius:3px}
|
||||
|
||||
|
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" }
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,13 @@
|
||||
<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>
|
||||
@@ -50,6 +57,10 @@
|
||||
<span class="f-val" id="srv-mem">—</span>
|
||||
<div class="f-minibar"><div class="f-minifill" id="srv-mem-bar"></div></div>
|
||||
</div>
|
||||
<div class="f-cell" style="gap:4px">
|
||||
<i class="fa-solid fa-code-branch" style="font-size:9px;color:var(--ink-4)"></i>
|
||||
<span id="srv-ver" style="font-family:var(--font-mono);font-size:9px;color:var(--ink-4)">—</span>
|
||||
</div>
|
||||
<div class="f-spacer"></div>
|
||||
<div class="f-right">
|
||||
<i class="fa-solid fa-rotate"></i>
|
||||
@@ -66,12 +77,12 @@
|
||||
<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">
|
||||
<input type="file" id="icon-upload" accept=".jpg,.jpeg,.png,.webp" style="display:none">
|
||||
<div style="flex:1">
|
||||
<div class="pop-host" id="pop-host">—</div>
|
||||
<div class="pop-ip" id="pop-ip">—</div>
|
||||
<div 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 id="icon-hint" style="font-size:10px;color:var(--ink-4);font-family:var(--font-terminal);margin-top:2px">
|
||||
Cliquer sur l'icône pour personnaliser · JPG PNG WEBP · max 128×128 px
|
||||
</div>
|
||||
</div>
|
||||
<div class="pop-led" id="pop-led"></div>
|
||||
@@ -147,6 +158,29 @@
|
||||
</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>
|
||||
|
||||
@@ -27,6 +27,11 @@ const API = (() => {
|
||||
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}`);
|
||||
@@ -44,6 +49,7 @@ const API = (() => {
|
||||
fd.append('icon', file);
|
||||
return postForm(`/api/agents/${id}/icon`, fd);
|
||||
},
|
||||
deleteAgent: (id) => del(`/api/agents/${id}`),
|
||||
iconUrl: (id) => `/api/agents/${id}/icon`,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -50,6 +50,8 @@ const App = (() => {
|
||||
const memEl = document.getElementById('srv-mem');
|
||||
const cpuBar = document.getElementById('srv-cpu-bar');
|
||||
const memBar = document.getElementById('srv-mem-bar');
|
||||
const verEl = document.getElementById('srv-ver');
|
||||
if (verEl && stats.version) verEl.textContent = 'v' + stats.version;
|
||||
if (cpuEl) {
|
||||
cpuEl.textContent = cpu.toFixed(0) + '%';
|
||||
cpuEl.className = 'f-val' + (cpu >= 70 ? ' w' : '');
|
||||
@@ -87,6 +89,8 @@ const App = (() => {
|
||||
updateServerStats(msg.data);
|
||||
} else if (msg.type === 'status_update') {
|
||||
Grid.updateStatus(msg.agent_id, msg.data.status);
|
||||
} else if (msg.type === 'agent_removed') {
|
||||
Grid.removeAgent(msg.agent_id);
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
@@ -105,7 +109,7 @@ const App = (() => {
|
||||
document.documentElement.style.setProperty('--tile-min', _serverConfig.tile_min_width + 'px');
|
||||
}
|
||||
if (_serverConfig.font_size) {
|
||||
document.body.style.fontSize = _serverConfig.font_size + 'px';
|
||||
document.documentElement.style.fontSize = _serverConfig.font_size + 'px';
|
||||
}
|
||||
if (_serverConfig.popup_detail_w && _serverConfig.popup_detail_h) {
|
||||
const pd = document.getElementById('popup-detail');
|
||||
|
||||
@@ -36,6 +36,40 @@ const Grid = (() => {
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderGaugeRow(faIcon, tip, label, pct, fillClass, valStr, extra) {
|
||||
const standard = (App.serverConfig?.gauge_type ?? 'compact') === 'standard';
|
||||
if (standard) {
|
||||
return `<div class="gs-row">
|
||||
<div class="gs-header">
|
||||
<span class="gs-ico" data-tip="${tip}"><i class="fa-solid fa-${faIcon}"></i></span>
|
||||
<span class="gs-lbl">${label}</span>
|
||||
<span class="gs-val">${valStr}</span>${extra || ''}
|
||||
</div>
|
||||
<div class="gs-bar">
|
||||
<div class="gs-fill ${fillClass}" style="width:${(pct ?? 0).toFixed(1)}%"></div>
|
||||
<div class="gs-gloss"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="g-row">
|
||||
<div class="g-ico" data-tip="${tip}"><i class="fa-solid fa-${faIcon}"></i></div>
|
||||
<div class="g-bar"><div class="g-fill ${fillClass}" style="width:${(pct ?? 0).toFixed(0)}%"></div></div>
|
||||
<span class="g-val">${valStr}</span>${extra || ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const _stateLabel = { ok: 'OK', old: 'USAGÉ', prefail: 'PREFAIL', hs: 'HS' };
|
||||
|
||||
function smartState(s) {
|
||||
if (!s.passed) return 'hs';
|
||||
if (s.reallocated_sectors > 0 ||
|
||||
(s.wear_level != null && s.wear_level < 20) ||
|
||||
(s.power_on_hours != null && s.power_on_hours > 40000)) return 'prefail';
|
||||
if ((s.wear_level != null && s.wear_level < 50) ||
|
||||
(s.power_on_hours != null && s.power_on_hours > 25000)) return 'old';
|
||||
return 'ok';
|
||||
}
|
||||
|
||||
function renderTile(agent, metrics) {
|
||||
const id = agent.id;
|
||||
const sc = statusClass(agent);
|
||||
@@ -55,10 +89,21 @@ const Grid = (() => {
|
||||
uptimeStr = d > 0 ? `${d}j ${h}h` : `${h}h`;
|
||||
}
|
||||
|
||||
const smartIco = !offline && metrics?.smart?.length > 0
|
||||
? '<div style="display:flex;gap:3px;flex-shrink:0">' +
|
||||
metrics.smart.map((s, i) => {
|
||||
const st = smartState(s);
|
||||
const lbl = _stateLabel[st];
|
||||
return `<span class="smart-pill ${st}"
|
||||
onclick="event.stopPropagation();Popups.showSmart('${esc(id)}',${i})"
|
||||
data-tip="SMART ${esc(s.device)} — ${lbl}">${lbl}</span>`;
|
||||
}).join('') + '</div>'
|
||||
: '';
|
||||
|
||||
const iconContent = `<img src="${API.iconUrl(id)}" alt=""
|
||||
style="width:100%;height:100%;object-fit:cover;border-radius:7px"
|
||||
onerror="this.style.display='none';this.nextSibling.style.display='flex'">
|
||||
<span style="display:flex;align-items:center;justify-content:center;width:100%;height:100%;color:var(--accent)">
|
||||
<span style="display:none;align-items:center;justify-content:center;width:100%;height:100%;color:var(--accent)">
|
||||
<i class="fa-solid fa-server"></i></span>`;
|
||||
|
||||
return `<div class="tile ${sc}" id="tile-${id}" onclick="Popups.showDetail('${esc(id)}')">
|
||||
@@ -71,36 +116,59 @@ const Grid = (() => {
|
||||
<div class="t-led ${ledClass(agent.status)}"></div>
|
||||
</div>
|
||||
<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 ? '—' : fmtPct(memPct)}</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>
|
||||
${renderGaugeRow('microchip', 'CPU', 'CPU',
|
||||
offline ? 0 : (cpu ?? 0),
|
||||
offline ? '' : gFill(cpu ?? 0),
|
||||
offline ? '—' : fmtPct(cpu))}
|
||||
${renderGaugeRow('memory', 'RAM', 'MÉMOIRE',
|
||||
offline ? 0 : (memPct ?? 0),
|
||||
offline ? '' : gFill(memPct ?? 0),
|
||||
offline ? '—' : (metrics?.memory_used && metrics?.memory_total ? fmt(metrics.memory_used) + '/' + fmt(metrics.memory_total) : '—'))}
|
||||
${renderGaugeRow('hard-drive', 'Disque', 'DISQUE',
|
||||
offline ? 0 : (diskPct ?? 0),
|
||||
offline ? '' : (diskPct >= (App.serverConfig?.warn_disk ?? 75) ? 'w' : ''),
|
||||
offline ? '—' : (metrics?.hdd_used && metrics?.hdd_total ? fmt(metrics.hdd_used) + '/' + fmt(metrics.hdd_total) : '—'),
|
||||
smartIco)}
|
||||
</div>
|
||||
<div class="tile-foot">
|
||||
${offline
|
||||
<span class="tile-foot-info">
|
||||
${offline
|
||||
? '<i class="fa-solid fa-circle-xmark" style="color:var(--err)"></i><span style="color:var(--err)">Hors ligne</span>'
|
||||
: `<i class="fa-solid fa-clock"></i><span>${uptimeStr}</span>`}
|
||||
: `<i class="fa-solid fa-clock"></i><span>${uptimeStr || '—'}</span>`}
|
||||
</span>
|
||||
<button class="btn-del-agent" title="Supprimer cet agent"
|
||||
onclick="event.stopPropagation();Popups.confirmDeleteAgent('${esc(id)}','${esc(agent.hostname)}')">
|
||||
<i class="fa-solid fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function update(agentId, metrics) {
|
||||
const entry = _agents.get(agentId);
|
||||
if (!entry) return;
|
||||
let entry = _agents.get(agentId);
|
||||
if (!entry) {
|
||||
// Nouvel agent découvert via WebSocket — on crée la tuile à la volée
|
||||
const agent = {
|
||||
id: agentId,
|
||||
hostname: metrics.hostname || agentId,
|
||||
ip: metrics.ip || '',
|
||||
status: metrics.status || 'online',
|
||||
};
|
||||
_agents.set(agentId, { agent, metrics });
|
||||
const grid = document.getElementById('agents-grid');
|
||||
if (grid) grid.insertAdjacentHTML('beforeend', renderTile(agent, metrics));
|
||||
updateStats();
|
||||
return;
|
||||
}
|
||||
// Mettre à jour ip/status depuis les métriques fraîches
|
||||
if (metrics.ip) entry.agent.ip = metrics.ip;
|
||||
if (metrics.status) entry.agent.status = metrics.status;
|
||||
// Conserver les valeurs lentes (disque, smart) quand le paquet ne les contient pas
|
||||
if (entry.metrics) {
|
||||
for (const k of Object.keys(entry.metrics)) {
|
||||
if (metrics[k] == null && entry.metrics[k] != null) metrics[k] = entry.metrics[k];
|
||||
}
|
||||
}
|
||||
entry.metrics = metrics;
|
||||
const el = document.getElementById('tile-' + agentId);
|
||||
if (el) {
|
||||
@@ -112,7 +180,7 @@ const Grid = (() => {
|
||||
function refresh(agents) {
|
||||
agents.forEach(a => {
|
||||
if (!_agents.has(a.id)) {
|
||||
_agents.set(a.id, { agent: a, metrics: null });
|
||||
_agents.set(a.id, { agent: a, metrics: a.last_metrics || null });
|
||||
} else {
|
||||
_agents.get(a.id).agent = a;
|
||||
}
|
||||
@@ -139,6 +207,19 @@ const Grid = (() => {
|
||||
document.getElementById('stat-err').textContent = err;
|
||||
}
|
||||
|
||||
function rerenderAll() {
|
||||
const grid = document.getElementById('agents-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = [..._agents.values()].map(({ agent, metrics }) => renderTile(agent, metrics)).join('');
|
||||
}
|
||||
|
||||
function removeAgent(id) {
|
||||
_agents.delete(id);
|
||||
const el = document.getElementById('tile-' + id);
|
||||
if (el) el.remove();
|
||||
updateStats();
|
||||
}
|
||||
|
||||
function getAgent(id) { return _agents.get(id); }
|
||||
|
||||
function updateStatus(agentId, status) {
|
||||
@@ -150,5 +231,5 @@ const Grid = (() => {
|
||||
updateStats();
|
||||
}
|
||||
|
||||
return { refresh, update, updateStatus, getAgent, fmt, fmtPct };
|
||||
return { refresh, update, updateStatus, removeAgent, rerenderAll, getAgent, fmt, fmtPct, smartState };
|
||||
})();
|
||||
|
||||
@@ -2,6 +2,7 @@ const Popups = (() => {
|
||||
let _currentAgentId = null;
|
||||
let _agentCfgData = null;
|
||||
let _resizeObs = null;
|
||||
let _resizeTimer = null;
|
||||
|
||||
// ══ POPUP DÉTAIL ══
|
||||
async function showDetail(agentId) {
|
||||
@@ -30,8 +31,27 @@ const Popups = (() => {
|
||||
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();
|
||||
const hint = document.getElementById('icon-hint');
|
||||
try {
|
||||
await API.uploadIcon(agentId, file);
|
||||
const ts = '?t=' + Date.now();
|
||||
img.src = API.iconUrl(agentId) + ts;
|
||||
img.style.display = 'block';
|
||||
document.getElementById('pop-icon-fa').style.display = 'none';
|
||||
const tileImg = document.querySelector(`#tile-${CSS.escape(agentId)} .t-icon img`);
|
||||
if (tileImg) tileImg.src = API.iconUrl(agentId) + ts;
|
||||
} catch (err) {
|
||||
if (hint) {
|
||||
hint.style.color = 'var(--err)';
|
||||
hint.textContent = 'Erreur : ' + (err.message || 'téléversement échoué');
|
||||
setTimeout(() => {
|
||||
hint.style.color = '';
|
||||
hint.textContent = 'Cliquer sur l\'icône pour personnaliser · JPG PNG WEBP · max 128×128 px';
|
||||
}, 4000);
|
||||
}
|
||||
} finally {
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Uptime
|
||||
@@ -50,15 +70,29 @@ const Popups = (() => {
|
||||
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>`
|
||||
let ramMin = null, ramMax = null;
|
||||
for (const h of history) {
|
||||
if (h.memory_used != null) {
|
||||
if (ramMin === null || h.memory_used < ramMin) ramMin = h.memory_used;
|
||||
if (ramMax === null || h.memory_used > ramMax) ramMax = h.memory_used;
|
||||
}
|
||||
}
|
||||
const ramMinMax = ramMin !== null
|
||||
? `<div class="chart-minmax"><span>min ${Grid.fmt(ramMin)}</span><span>max ${Grid.fmt(ramMax)}</span></div>`
|
||||
: '';
|
||||
|
||||
const smartBadges = metrics?.smart?.length > 0
|
||||
? '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:6px">' +
|
||||
metrics.smart.map((s, i) => {
|
||||
const st = Grid.smartState(s);
|
||||
const lbl = { ok: 'OK', old: 'USAGÉ', prefail: 'PREFAIL', hs: 'HS' }[st];
|
||||
return `<span class="smart-pill ${st}"
|
||||
onclick="Popups.showSmart('${esc(agentId)}',${i})"
|
||||
data-tip="Santé SMART de ${esc(s.device)}">
|
||||
<i class="fa-solid fa-hard-drive" style="font-size:8px"></i>
|
||||
${esc(s.device)} · ${lbl}
|
||||
</span>`;
|
||||
}).join('') + '</div>'
|
||||
: '';
|
||||
|
||||
const protos = [
|
||||
@@ -95,10 +129,14 @@ const Popups = (() => {
|
||||
<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 style="display:flex;align-items:baseline;gap:5px">
|
||||
<span class="chart-cur" style="color:var(--blue)">${Grid.fmtPct(metrics?.memory_used && metrics?.memory_total ? metrics.memory_used / metrics.memory_total * 100 : null)}</span>
|
||||
<span style="font-family:var(--font-mono);font-size:10px;color:var(--ink-3)">${Grid.fmt(metrics?.memory_used)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<svg class="chart-svg" viewBox="0 0 200 52" preserveAspectRatio="none" id="det-mem-chart"></svg>
|
||||
<div class="chart-axis"><span>−30min</span><span>−15min</span><span>now</span></div>
|
||||
${ramMinMax}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,14 +149,58 @@ const Popups = (() => {
|
||||
<div style="height:100%;border-radius:4px;background:var(--ok);width:${metrics?.hdd_total ? (metrics.hdd_used/metrics.hdd_total*100).toFixed(0) : 0}%"></div></div>
|
||||
<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}
|
||||
${smartBadges}
|
||||
</div>
|
||||
</div>
|
||||
${(() => {
|
||||
const ni = entry?.agent?.network_info;
|
||||
if (!ni?.length) return '';
|
||||
const wol = v => v == null ? '—' : v ? '<span style="color:var(--ok)">Oui</span>' : '<span style="color:var(--ink-4)">Non</span>';
|
||||
const spd = v => v == null ? '—' : v >= 1000 ? '1 Gb' : v + ' Mb';
|
||||
const rows = ni.map(iface => `
|
||||
<div class="net-row">
|
||||
<span style="color:var(--ink-3);font-size:12px"><i class="fa-solid fa-${iface.if_type === 'wifi' ? 'wifi' : 'ethernet'}"></i></span>
|
||||
<span style="color:var(--ink-1);font-weight:600">${esc(iface.name)}</span>
|
||||
<span style="color:var(--ink-3)">${spd(iface.speed_mbps)}</span>
|
||||
<span style="color:var(--ink-4);font-size:9px;letter-spacing:.04em">${esc(iface.mac)}</span>
|
||||
<span>WoL : ${wol(iface.wol)}</span>
|
||||
<span style="color:var(--blue)">${iface.iperf_mbps != null ? iface.iperf_mbps.toFixed(1) + ' Mb/s' : '—'}</span>
|
||||
</div>`).join('');
|
||||
return `<div>
|
||||
<div class="sec-title">RÉSEAU</div>
|
||||
<div class="net-table">
|
||||
<div class="net-row" style="background:var(--bg-4);font-size:9px;color:var(--ink-4);letter-spacing:.06em">
|
||||
<span></span><span>INTERFACE</span><span>VITESSE</span><span>MAC</span><span>WAKE ON LAN</span><span>IPERF3</span>
|
||||
</div>
|
||||
${rows}
|
||||
</div>
|
||||
</div>`;
|
||||
})()}
|
||||
${(() => {
|
||||
const hw = entry?.agent?.hardware_info;
|
||||
if (!hw) return '';
|
||||
const row = (lbl, val) => val ? `<div class="meta"><div class="meta-lbl">${lbl}</div><div class="meta-val">${esc(String(val))}</div></div>` : '';
|
||||
const ramSlots = hw.ram_slots_used != null && hw.ram_slots_total != null
|
||||
? `${hw.ram_slots_used}/${hw.ram_slots_total} slots` : null;
|
||||
const ramInfo = [hw.ram_type, hw.ram_speed_mhz ? hw.ram_speed_mhz + ' MHz' : null, ramSlots]
|
||||
.filter(Boolean).join(' · ') || null;
|
||||
return `<div>
|
||||
<div class="sec-title">HARDWARE</div>
|
||||
<div class="meta-grid">
|
||||
${row('CARTE MÈRE', hw.motherboard_vendor && hw.motherboard_model ? hw.motherboard_vendor + ' ' + hw.motherboard_model : hw.motherboard_model || hw.motherboard_vendor)}
|
||||
${row('PROCESSEUR', hw.cpu_model)}
|
||||
${row('MÉMOIRE RAM', ramInfo)}
|
||||
</div>
|
||||
</div>`;
|
||||
})()}
|
||||
<div>
|
||||
<div class="sec-title">INFORMATIONS</div>
|
||||
<div class="meta-grid">
|
||||
<div class="meta"><div class="meta-lbl">HOSTNAME</div><div class="meta-val">${esc(agent.hostname)}</div></div>
|
||||
<div class="meta"><div class="meta-lbl">ADRESSE IP</div><div class="meta-val">${esc(agent.ip) || '—'}</div></div>
|
||||
<div class="meta"><div class="meta-lbl">VERSION AGENT</div><div class="meta-val" style="display:flex;align-items:center;gap:6px">
|
||||
${agent.version ? `<span style="font-family:var(--font-mono);background:var(--bg-1);border:1px solid var(--border-2);border-radius:5px;padding:1px 7px;font-size:11px;color:var(--accent)">v${esc(agent.version)}</span>` : '<span style="color:var(--ink-4)">—</span>'}
|
||||
</div></div>
|
||||
<div class="meta"><div class="meta-lbl">PROTOCOLES ACTIFS</div><div style="display:flex;gap:5px;margin-top:4px">${protos || '—'}</div></div>
|
||||
<div class="meta"><div class="meta-lbl">DERNIER CONTACT</div><div class="meta-val">${new Date(agent.last_seen * 1000).toLocaleTimeString('fr-FR')}</div></div>
|
||||
</div>
|
||||
@@ -133,11 +215,14 @@ const Popups = (() => {
|
||||
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(() => {});
|
||||
clearTimeout(_resizeTimer);
|
||||
_resizeTimer = setTimeout(() => {
|
||||
API.putServerConfig({
|
||||
...App.serverConfig,
|
||||
popup_detail_w: pd.offsetWidth,
|
||||
popup_detail_h: pd.offsetHeight,
|
||||
}).catch(() => {});
|
||||
}, 600);
|
||||
});
|
||||
_resizeObs.observe(pd);
|
||||
|
||||
@@ -278,6 +363,11 @@ const Popups = (() => {
|
||||
<input type="range" class="scfg-slider" min="10" max="18" value="${cfg.font_size ?? 13}"
|
||||
oninput="this.nextElementSibling.textContent=this.value+'px'" id="s-font">
|
||||
<span class="scfg-val">${cfg.font_size ?? 13}px</span></div>
|
||||
<div class="scfg-row"><label>Type de jauge</label>
|
||||
<select class="scfg-select" id="s-gauge-type">
|
||||
<option value="compact" ${(cfg.gauge_type ?? 'compact') === 'compact' ? 'selected' : ''}>Compact</option>
|
||||
<option value="standard" ${(cfg.gauge_type ?? 'compact') === 'standard' ? 'selected' : ''}>Standard</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div class="scfg-sec-title">SEUILS D'ALERTE</div>
|
||||
@@ -305,6 +395,20 @@ const Popups = (() => {
|
||||
${[[15,'15 min'],[30,'30 min'],[60,'1 heure'],[360,'6 heures']].map(([v,l]) =>
|
||||
`<option value="${v}" ${(cfg.chart_duration_min??30)==v?'selected':''}>${l}</option>`).join('')}
|
||||
</select></div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:8px">
|
||||
<div class="scfg-sec-title">INSTALLATION AGENT</div>
|
||||
<div class="scfg-row" style="flex-direction:column;align-items:stretch;gap:6px">
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<input type="text" id="s-install-cmd" readonly
|
||||
style="flex:1;font-family:var(--font-mono);font-size:0.78em;padding:6px 8px;
|
||||
background:var(--bg2);border:1px solid var(--border);border-radius:6px;
|
||||
color:var(--fg);cursor:text;min-width:0"
|
||||
value="SERVER_IP=${window.location.hostname} curl -fsSL https://git.maison43gil.com/gilles/nano_metrics/raw/branch/main/deploy/install.sh | sudo bash">
|
||||
<button class="btn" style="padding:5px 10px;font-size:0.8em;white-space:nowrap"
|
||||
onclick="Popups._copyInstallCmd(this)">Copier</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('overlay-srvcfg').style.display = 'flex';
|
||||
}
|
||||
@@ -324,66 +428,110 @@ const Popups = (() => {
|
||||
warn_disk: parseInt(document.getElementById('s-warn-disk')?.value ?? 75),
|
||||
retention_days: parseInt(document.getElementById('s-retention')?.value ?? 30),
|
||||
chart_duration_min: parseInt(document.getElementById('s-chart-dur')?.value ?? 30),
|
||||
gauge_type: document.getElementById('s-gauge-type')?.value ?? 'compact',
|
||||
};
|
||||
const prevGaugeType = App.serverConfig?.gauge_type ?? 'compact';
|
||||
await API.putServerConfig(cfg);
|
||||
App.serverConfig = cfg;
|
||||
document.documentElement.style.setProperty('--tile-min', cfg.tile_min_width + 'px');
|
||||
document.body.style.fontSize = cfg.font_size + 'px';
|
||||
if (cfg.gauge_type !== prevGaugeType) Grid.rerenderAll();
|
||||
hideSrvCfg();
|
||||
}
|
||||
|
||||
// ══ POPUP SMART ══
|
||||
function showSmart(agentId) {
|
||||
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.';
|
||||
function showSmart(agentId, diskIdx = 0) {
|
||||
const smartList = Grid.getAgent(agentId)?.metrics?.smart;
|
||||
if (!smartList?.length) return;
|
||||
const m = smartList[diskIdx] ?? smartList[0];
|
||||
const state = Grid.smartState(m);
|
||||
|
||||
document.getElementById('smart-sub').textContent = m.device ? `${agentId} — ${m.device}` : agentId;
|
||||
|
||||
const stateInfo = {
|
||||
ok: { color:'var(--ok)', bg:'rgba(77,187,38,.1)', border:'rgba(77,187,38,.3)', icon:'fa-circle-check',
|
||||
title:'Disque en bonne santé',
|
||||
desc:'Aucun problème détecté. Votre disque fonctionne normalement.' },
|
||||
old: { color:'var(--warn)', bg:'rgba(250,189,47,.1)', border:'rgba(250,189,47,.3)', icon:'fa-clock-rotate-left',
|
||||
title:'Disque ancien ou très utilisé',
|
||||
desc:'Votre disque fonctionne encore, mais il a accumulé beaucoup d\'heures. Pensez à prévoir un remplacement.' },
|
||||
prefail: { color:'var(--accent)', bg:'var(--accent-tint)', border:'rgba(254,128,25,.3)', icon:'fa-triangle-exclamation',
|
||||
title:'Signes de défaillance imminente',
|
||||
desc:'Ce disque présente des indicateurs préoccupants. Sauvegardez vos données dès maintenant et envisagez un remplacement rapide.' },
|
||||
hs: { color:'var(--err)', bg:'rgba(251,73,52,.1)', border:'rgba(251,73,52,.3)', icon:'fa-circle-xmark',
|
||||
title:'Disque défaillant',
|
||||
desc:'Ce disque a échoué au test SMART. Il peut tomber en panne à tout moment. Sauvegardez immédiatement et remplacez-le.' },
|
||||
};
|
||||
const si = stateInfo[state];
|
||||
|
||||
const tempColor = m.temperature == null ? null
|
||||
: m.temperature > 60 ? 'var(--err)' : m.temperature > 50 ? 'var(--warn)' : 'var(--ok)';
|
||||
const tempLabel = m.temperature == null ? null
|
||||
: m.temperature > 60 ? 'Critique' : m.temperature > 50 ? 'Élevée' : 'Normale';
|
||||
const tempBg = tempColor === 'var(--ok)' ? 'rgba(77,187,38,.15)'
|
||||
: tempColor === 'var(--warn)' ? 'rgba(250,189,47,.15)' : 'rgba(251,73,52,.15)';
|
||||
|
||||
const secColor = m.reallocated_sectors == null ? null
|
||||
: m.reallocated_sectors === 0 ? 'var(--ok)' : m.reallocated_sectors < 10 ? 'var(--warn)' : 'var(--err)';
|
||||
const secDesc = m.reallocated_sectors === 0
|
||||
? 'Aucun secteur défectueux — parfait.'
|
||||
: m.reallocated_sectors < 10 ? 'Quelques secteurs remplacés. Surveillez l\'évolution.'
|
||||
: 'Nombreux secteurs défectueux — risque de panne élevé.';
|
||||
|
||||
const hoursColor = m.power_on_hours == null ? null
|
||||
: m.power_on_hours > 40000 ? 'var(--err)' : m.power_on_hours > 25000 ? 'var(--warn)' : 'var(--ok)';
|
||||
|
||||
const wearColor = m.wear_level == null ? null
|
||||
: m.wear_level < 20 ? 'var(--err)' : m.wear_level < 50 ? 'var(--warn)' : 'var(--ok)';
|
||||
const wearDesc = m.wear_level == null ? ''
|
||||
: m.wear_level >= 80 ? 'Très bonne durée de vie restante.'
|
||||
: m.wear_level >= 50 ? 'Durée de vie acceptable, à surveiller.'
|
||||
: m.wear_level >= 20 ? 'Durée de vie réduite — pensez au remplacement.'
|
||||
: 'Durée de vie critique — remplacez ce SSD rapidement.';
|
||||
|
||||
document.getElementById('smart-body').innerHTML = `
|
||||
<div class="smart-verdict" style="${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 class="smart-verdict" style="background:${si.bg};border-color:${si.border}">
|
||||
<div style="font-size:28px;color:${si.color}"><i class="fa-solid ${si.icon}"></i></div>
|
||||
<div>
|
||||
<div style="font-size:16px;font-weight:700;color:${si.color}">${si.title}</div>
|
||||
<div style="font-size:12px;color:var(--ink-3);margin-top:3px">${si.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="sec-title">POINTS DE CONTRÔLE</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
${m.temperature != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<span style="color:var(--warn);font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-temperature-half"></i></span>
|
||||
<span style="color:${tempColor};font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-temperature-half"></i></span>
|
||||
<span style="font-weight:600;font-size:12px;flex:1">Température</span>
|
||||
<span style="font-size:10px;font-family:var(--font-terminal);font-weight:700;padding:1px 7px;border-radius:999px;background:rgba(77,187,38,.15);color:var(--ok)">Normale</span>
|
||||
<span style="font-size:10px;font-family:var(--font-terminal);font-weight:700;padding:1px 7px;border-radius:999px;background:${tempBg};color:${tempColor}">${tempLabel}</span>
|
||||
</div>
|
||||
<div 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 class="si-desc">Normale entre 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="color:${secColor};font-size:14px;width:22px;text-align:center"><i class="fa-solid ${m.reallocated_sectors === 0 ? 'fa-circle-check' : 'fa-circle-exclamation'}"></i></span>
|
||||
<span style="font-weight:600;font-size:12px;flex:1">Secteurs défectueux</span>
|
||||
</div>
|
||||
<div class="si-val">${m.reallocated_sectors}<span class="u"> sect.</span></div>
|
||||
<div class="si-desc">S'ils apparaissent en grand nombre, une panne est imminente.</div>
|
||||
<div class="si-desc">${secDesc}</div>
|
||||
</div>` : ''}
|
||||
${m.power_on_hours != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<span style="color: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>
|
||||
<span style="color:${hoursColor};font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-clock-rotate-left"></i></span>
|
||||
<span style="font-weight:600;font-size:12px;flex:1">Durée de fonctionnement</span>
|
||||
</div>
|
||||
<div class="si-val">${m.power_on_hours.toLocaleString('fr-FR')}<span class="u">h</span></div>
|
||||
<div class="si-desc">≈${Math.floor(m.power_on_hours/24)} jours. Un disque dure en moyenne 3 à 5 ans.</div>
|
||||
<div class="si-val">${m.power_on_hours.toLocaleString('fr-FR')}<span class="u"> h</span></div>
|
||||
<div class="si-desc">≈${Math.floor(m.power_on_hours / 24)} jours d'utilisation. Un disque dur dure en moyenne 3 à 5 ans (25 000–40 000 h).</div>
|
||||
</div>` : ''}
|
||||
${m.wear_level != null ? `<div style="background:var(--bg-3);border-radius:8px;padding:12px 14px;border:1px solid var(--border-1)">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
||||
<span style="color: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>
|
||||
<span style="color:${wearColor};font-size:14px;width:22px;text-align:center"><i class="fa-solid fa-battery-full"></i></span>
|
||||
<span style="font-weight:600;font-size:12px;flex:1">Durée de vie SSD restante</span>
|
||||
</div>
|
||||
<div class="si-val">${m.wear_level}<span class="u">%</span></div>
|
||||
<div class="si-desc">100% = neuf · 0% = fin de vie recommandée.</div>
|
||||
<div class="si-desc">${wearDesc}</div>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>`;
|
||||
@@ -391,10 +539,53 @@ const Popups = (() => {
|
||||
document.getElementById('overlay-smart').style.display = 'flex';
|
||||
}
|
||||
|
||||
// ══ SUPPRESSION AGENT ══
|
||||
let _delAgentId = null;
|
||||
|
||||
function confirmDeleteAgent(id, hostname) {
|
||||
_delAgentId = id;
|
||||
document.getElementById('del-agent-name').textContent = hostname || id;
|
||||
document.getElementById('overlay-del').style.display = 'flex';
|
||||
}
|
||||
|
||||
async function doDeleteAgent() {
|
||||
if (!_delAgentId) return;
|
||||
const id = _delAgentId;
|
||||
document.getElementById('overlay-del').style.display = 'none';
|
||||
_delAgentId = null;
|
||||
try {
|
||||
await API.deleteAgent(id);
|
||||
Grid.removeAgent(id);
|
||||
} catch (e) {
|
||||
alert('Erreur lors de la suppression : ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
function _copyInstallCmd(btn) {
|
||||
const text = document.getElementById('s-install-cmd').value;
|
||||
const done = () => { btn.textContent = '✓ Copié'; setTimeout(() => btn.textContent = 'Copier', 1500); };
|
||||
if (window.isSecureContext && navigator.clipboard?.writeText) {
|
||||
navigator.clipboard.writeText(text).then(done).catch(() => _copyFallback(text, done));
|
||||
} else {
|
||||
_copyFallback(text, done);
|
||||
}
|
||||
}
|
||||
|
||||
function _copyFallback(text, cb) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;opacity:0;top:0;left:0';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try { document.execCommand('copy'); cb(); } catch (_) {}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
return {
|
||||
showDetail, hideDetail,
|
||||
showAgentCfg, sendAgentConfig, toggleCbox,
|
||||
showSrvCfg, hideSrvCfg, saveSrvCfg,
|
||||
showSmart,
|
||||
showSrvCfg, hideSrvCfg, saveSrvCfg, confirmDeleteAgent, doDeleteAgent,
|
||||
showSmart, _copyInstallCmd,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# Nanometrics: accès groupe disk aux contrôleurs NVMe pour SMART
|
||||
# Sans cette règle, /dev/nvme0 est crw------- root root (root only),
|
||||
# ce qui empêche smartctl d'exécuter les commandes admin et omet smart_status du JSON.
|
||||
KERNEL=="nvme[0-9]*", SUBSYSTEM=="nvme", GROUP="disk", MODE="0660"
|
||||
@@ -2,7 +2,7 @@
|
||||
# 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
|
||||
# SERVER_IP=10.0.0.82 SERVER_PORT=9999 curl -fsSL ... | bash
|
||||
set -euo pipefail
|
||||
|
||||
REPO_API="https://git.maison43gil.com/api/v1/repos/gilles/nano_metrics"
|
||||
@@ -29,7 +29,34 @@ echo " Nanometrics Agent — Installation"
|
||||
echo "======================================"
|
||||
echo ""
|
||||
|
||||
# ── 1. Détection de l'architecture ────────────────────────────────────────────
|
||||
# ── 1. Dépendances système ─────────────────────────────────────────────────────
|
||||
PKGS_NEEDED=()
|
||||
for pkg in curl python3 smartmontools ethtool iperf3 dmidecode; do
|
||||
dpkg -l "$pkg" 2>/dev/null | grep -q '^ii' || PKGS_NEEDED+=("$pkg")
|
||||
done
|
||||
|
||||
if [ ${#PKGS_NEEDED[@]} -gt 0 ]; then
|
||||
echo "→ Installation des paquets manquants : ${PKGS_NEEDED[*]}"
|
||||
apt-get update -qq
|
||||
apt-get install -y -qq "${PKGS_NEEDED[@]}"
|
||||
ok "Paquets installés : ${PKGS_NEEDED[*]}"
|
||||
else
|
||||
ok "Dépendances système déjà présentes"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ── 2. Règle udev NVMe (accès SMART pour le groupe disk) ──────────────────────
|
||||
UDEV_RULE="/etc/udev/rules.d/99-nanometrics-smart.rules"
|
||||
cat > "$UDEV_RULE" << 'UDEVRULE'
|
||||
# Nanometrics: accès groupe disk aux contrôleurs NVMe pour SMART
|
||||
KERNEL=="nvme[0-9]*", SUBSYSTEM=="nvme", GROUP="disk", MODE="0660"
|
||||
UDEVRULE
|
||||
udevadm control --reload-rules
|
||||
udevadm trigger --subsystem-match=nvme 2>/dev/null || true
|
||||
ok "Règle udev NVMe installée ($UDEV_RULE)"
|
||||
echo ""
|
||||
|
||||
# ── 3. Détection de l'architecture ────────────────────────────────────────────
|
||||
ARCH="$(uname -m)"
|
||||
case "$ARCH" in
|
||||
x86_64) LABEL="linux-amd64" ;;
|
||||
@@ -42,7 +69,7 @@ case "$ARCH" in
|
||||
esac
|
||||
ok "Architecture détectée : $ARCH → $LABEL"
|
||||
|
||||
# ── 2. Récupérer l'URL du binaire depuis la dernière release ──────────────────
|
||||
# ── 4. Récupérer l'URL du binaire depuis la dernière release ──────────────────
|
||||
echo "→ Récupération de la dernière release..."
|
||||
|
||||
ASSETS_JSON=$(curl -sf "$REPO_API/releases?limit=1&page=1")
|
||||
@@ -69,7 +96,7 @@ print(releases[0]['tag_name'])
|
||||
|
||||
ok "Release : $TAG — URL : $ASSET_URL"
|
||||
|
||||
# ── 3. Télécharger le binaire ─────────────────────────────────────────────────
|
||||
# ── 5. Télécharger le binaire ─────────────────────────────────────────────────
|
||||
TMP_BIN="$(mktemp)"
|
||||
trap 'rm -f "$TMP_BIN"' EXIT
|
||||
|
||||
@@ -78,43 +105,40 @@ 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 ────────────────────────────────────────────
|
||||
# ── 6. Paramètres de configuration ────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "--- Configuration du serveur ---"
|
||||
|
||||
if [ -z "${SERVER_IP:-}" ]; then
|
||||
read -rp "Adresse IP du serveur Nanometrics : " SERVER_IP
|
||||
fi
|
||||
if [ -z "${SERVER_IP:-}" ]; then
|
||||
err "SERVER_IP est requis."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVER_IP="${SERVER_IP:-10.0.0.82}"
|
||||
SERVER_PORT="${SERVER_PORT:-9999}"
|
||||
MQTT_HOST="${MQTT_HOST:-10.0.0.3}"
|
||||
MQTT_ENABLED="${MQTT_ENABLED:-false}"
|
||||
|
||||
ok "Serveur : $SERVER_IP:$SERVER_PORT"
|
||||
ok "Serveur : $SERVER_IP:$SERVER_PORT | MQTT broker : $MQTT_HOST"
|
||||
|
||||
# ── 5. Installer le binaire ────────────────────────────────────────────────────
|
||||
# ── 7. Installer le binaire ────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "[1/5] Installation du binaire dans /usr/local/bin/"
|
||||
echo "[1/5] Installation du binaire..."
|
||||
|
||||
# Arrêter le service si en cours (le binaire ne peut pas être écrasé à chaud)
|
||||
if systemctl is-active --quiet nanometrics-agent 2>/dev/null; then
|
||||
warn "Service en cours — arrêt temporaire..."
|
||||
systemctl stop nanometrics-agent
|
||||
fi
|
||||
|
||||
cp "$TMP_BIN" "$INSTALL_BIN"
|
||||
chmod 755 "$INSTALL_BIN"
|
||||
ok "Binaire installé"
|
||||
|
||||
# ── 6. Créer le répertoire de configuration ───────────────────────────────────
|
||||
# ── 8. Créer le répertoire de configuration ───────────────────────────────────
|
||||
echo "[2/5] Création de $CONFIG_DIR"
|
||||
mkdir -p "$CONFIG_DIR"
|
||||
chmod 755 "$CONFIG_DIR"
|
||||
ok "Répertoire créé"
|
||||
|
||||
# ── 7. Écrire config.toml ─────────────────────────────────────────────────────
|
||||
# ── 9. É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"
|
||||
@@ -125,7 +149,7 @@ enabled = true
|
||||
|
||||
[protocols.mqtt]
|
||||
enabled = $MQTT_ENABLED
|
||||
host = "10.0.0.3"
|
||||
host = "$MQTT_HOST"
|
||||
port = 1883
|
||||
topic_base = "nanometrics/agents"
|
||||
auto_discovery = true
|
||||
@@ -160,11 +184,14 @@ mqtt = false
|
||||
udp = true
|
||||
mqtt = false
|
||||
TOML
|
||||
chmod 640 "$CONFIG_FILE"
|
||||
ok "config.toml créé"
|
||||
fi
|
||||
chmod 644 "$CONFIG_FILE"
|
||||
ok "config.toml écrit"
|
||||
|
||||
# ── 8. Installer le fichier service ──────────────────────────────────────────
|
||||
# S'assurer que le répertoire est accessible
|
||||
chmod 644 "$CONFIG_FILE" 2>/dev/null || true
|
||||
chmod 755 "$CONFIG_DIR"
|
||||
|
||||
# ── 10. Installer le fichier service ─────────────────────────────────────────
|
||||
echo "[4/5] Installation du service systemd"
|
||||
curl -fsSL -o "$SERVICE_FILE" "$SERVICE_URL"
|
||||
chmod 644 "$SERVICE_FILE"
|
||||
@@ -172,7 +199,7 @@ systemctl daemon-reload
|
||||
systemctl enable nanometrics-agent
|
||||
ok "Service installé et activé"
|
||||
|
||||
# ── 9. Démarrer le service ────────────────────────────────────────────────────
|
||||
# ── 11. Démarrer le service ───────────────────────────────────────────────────
|
||||
echo "[5/5] Démarrage du service"
|
||||
systemctl restart nanometrics-agent
|
||||
sleep 2
|
||||
@@ -182,6 +209,8 @@ echo "=== Statut ==="
|
||||
systemctl status nanometrics-agent --no-pager || true
|
||||
|
||||
echo ""
|
||||
ok "Installation terminée — agent $TAG opérationnel"
|
||||
echo "======================================"
|
||||
echo -e " ${GREEN}✓${NC} Nanometrics Agent ${TAG} installé"
|
||||
echo "======================================"
|
||||
echo " Config : $CONFIG_FILE"
|
||||
echo " Logs : journalctl -u nanometrics-agent -f"
|
||||
|
||||
@@ -10,13 +10,19 @@ Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
DynamicUser=yes
|
||||
SupplementaryGroups=disk
|
||||
ConfigurationDirectory=nanometrics
|
||||
ConfigurationDirectoryMode=0750
|
||||
ConfigurationDirectoryMode=0755
|
||||
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
PrivateTmp=yes
|
||||
NoNewPrivileges=yes
|
||||
# CAP_SYS_ADMIN est requis par le noyau pour NVME_IOCTL_ADMIN_CMD (lecture SMART NVMe).
|
||||
# NoNewPrivileges est retiré car il efface les ambient capabilities sur exec (noyau ≥ 5.2),
|
||||
# ce qui empêcherait smartctl enfant d'hériter la capability.
|
||||
# CapabilityBoundingSet borne à la seule cap nécessaire.
|
||||
CapabilityBoundingSet=CAP_SYS_ADMIN
|
||||
AmbientCapabilities=CAP_SYS_ADMIN
|
||||
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
|
||||
|
||||
|
||||
@@ -25,7 +25,9 @@ DESCRIPTION="${2:-Release $TAG}"
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
CARGO_TOML="$ROOT/agent/Cargo.toml"
|
||||
|
||||
# ── 1. Compiler pour toutes les cibles supportées ──────────────────────────
|
||||
mkdir -p "$ROOT/dist"
|
||||
|
||||
# ── 1. Compiler l'agent pour toutes les cibles supportées ────────────────
|
||||
echo "=== Compilation de l'agent ==="
|
||||
TARGETS=("x86_64-unknown-linux-musl" "aarch64-unknown-linux-musl")
|
||||
LABELS=("linux-amd64" "linux-arm64")
|
||||
@@ -40,7 +42,9 @@ for i in "${!TARGETS[@]}"; do
|
||||
# Installer la cible si absente
|
||||
rustup target add "$TARGET" 2>/dev/null || true
|
||||
|
||||
# Compiler
|
||||
# Compiler (CC requis par la crate ring pour les cibles musl cross)
|
||||
CC_aarch64_unknown_linux_musl=aarch64-linux-gnu-gcc \
|
||||
AR_aarch64_unknown_linux_musl=aarch64-linux-gnu-ar \
|
||||
cargo build --release \
|
||||
--manifest-path "$CARGO_TOML" \
|
||||
--target "$TARGET" \
|
||||
|
||||
@@ -79,7 +79,7 @@ tempfile = "3"
|
||||
|
||||
```toml
|
||||
[server]
|
||||
ip = "10.0.0.50"
|
||||
ip = "10.0.0.82"
|
||||
port = 9999
|
||||
|
||||
[protocols.udp]
|
||||
@@ -172,7 +172,7 @@ fn test_config_parse_complet() {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
write!(f, r#"
|
||||
[server]
|
||||
ip = "10.0.0.50"
|
||||
ip = "10.0.0.82"
|
||||
port = 9999
|
||||
|
||||
[protocols.udp]
|
||||
@@ -192,7 +192,7 @@ udp = true
|
||||
mqtt = false
|
||||
"#).unwrap();
|
||||
let cfg = nanometrics_agent::config::load(f.path()).unwrap();
|
||||
assert_eq!(cfg.server.ip, "10.0.0.50");
|
||||
assert_eq!(cfg.server.ip, "10.0.0.82");
|
||||
assert_eq!(cfg.server.port, 9999);
|
||||
assert!(cfg.protocols.udp.enabled);
|
||||
assert!(cfg.protocols.mqtt.enabled);
|
||||
@@ -206,7 +206,7 @@ fn test_config_mqtt_absent() {
|
||||
let mut f = NamedTempFile::new().unwrap();
|
||||
write!(f, r#"
|
||||
[server]
|
||||
ip = "10.0.0.50"
|
||||
ip = "10.0.0.82"
|
||||
port = 9999
|
||||
|
||||
[protocols.udp]
|
||||
|
||||
@@ -0,0 +1,644 @@
|
||||
# Améliorations Nanometrics — Plan d'implémentation
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Ajouter métriques réseau enrichies, hardware, config bidirectionnelle, API REST complète, taille police globale.
|
||||
|
||||
**Architecture:**
|
||||
- Métriques lentes (réseau, hardware) : collecte au démarrage + une fois/jour à heure fixe (config `slow_daily_time`)
|
||||
- Stockage dans la table `agents` (colonnes JSON), pas dans `metrics` — ces données changent rarement
|
||||
- API REST expose tout via les mêmes endpoints enrichis
|
||||
|
||||
**Tech Stack:** Rust (agent), Go (server), SQLite, Vanilla JS (dashboard)
|
||||
|
||||
---
|
||||
|
||||
## Fichiers concernés
|
||||
|
||||
| Fichier | Action |
|
||||
|---------|--------|
|
||||
| `agent/src/payload.rs` | Ajout `NetworkInterface`, `HardwareInfo`, champs dans `AgentMetrics` |
|
||||
| `agent/src/config.rs` | Ajout `slow_daily_time`, `network_info`, `hardware_info` dans `MetricsConfig` |
|
||||
| `agent/src/metrics/network_info.rs` | Nouveau module |
|
||||
| `agent/src/metrics/hardware.rs` | Nouveau module |
|
||||
| `agent/src/metrics/mod.rs` | Déclarer les 2 nouveaux modules |
|
||||
| `agent/src/main.rs` | Intégration scheduler, collecte slow |
|
||||
| `agent/Cargo.toml` | Bump version 0.1.6 |
|
||||
| `deploy/install.sh` | Ajout `iperf3`, `dmidecode` dans paquets |
|
||||
| `server/models/models.go` | Structs Go `NetworkInterface`, `HardwareInfo` |
|
||||
| `server/db/db.go` | Migrations + `UpsertAgent` + `GetLastMetrics` |
|
||||
| `server/handlers/agents.go` | Handler GET `/api/agents/{id}` |
|
||||
| `server/main.go` | Route `/api/agents/{id}` |
|
||||
| `server/docker-compose.yml` | Service iperf3 |
|
||||
| `dashboard/js/popups.js` | Sections réseau + hardware dans popup détail |
|
||||
| `dashboard/css/app.css` | Styles network/hardware section + fix font-size global |
|
||||
| `dashboard/js/app.js` | Fix font-size sur `html` element |
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Agent : structs payload + config
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent/src/payload.rs`
|
||||
- Modify: `agent/src/config.rs`
|
||||
|
||||
- [ ] **Ajouter dans `payload.rs`** les nouveaux types et champs :
|
||||
|
||||
```rust
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct NetworkInterface {
|
||||
pub name: String,
|
||||
pub if_type: String, // "ethernet" | "wifi"
|
||||
pub speed_mbps: Option<i64>,
|
||||
pub mac: String,
|
||||
pub wol: Option<bool>,
|
||||
pub iperf_mbps: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
|
||||
pub struct HardwareInfo {
|
||||
pub motherboard_vendor: Option<String>,
|
||||
pub motherboard_model: Option<String>,
|
||||
pub cpu_model: Option<String>,
|
||||
pub ram_type: Option<String>,
|
||||
pub ram_speed_mhz: Option<i64>,
|
||||
pub ram_slots_used: Option<i64>,
|
||||
pub ram_slots_total: Option<i64>,
|
||||
}
|
||||
```
|
||||
|
||||
Dans `AgentMetrics`, ajouter après `smart` :
|
||||
```rust
|
||||
pub network_info: Option<Vec<NetworkInterface>>,
|
||||
pub hardware_info: Option<HardwareInfo>,
|
||||
```
|
||||
|
||||
- [ ] **Ajouter dans `config.rs`** — `SlowMetricsConfig` + champs dans `MetricsConfig` :
|
||||
|
||||
```rust
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct SlowMetricsConfig {
|
||||
#[serde(default)]
|
||||
pub udp: bool,
|
||||
#[serde(default)]
|
||||
pub mqtt: bool,
|
||||
}
|
||||
|
||||
impl Default for SlowMetricsConfig {
|
||||
fn default() -> Self { Self { udp: true, mqtt: false } }
|
||||
}
|
||||
```
|
||||
|
||||
Dans `MetricsConfig`, ajouter :
|
||||
```rust
|
||||
#[serde(default)]
|
||||
pub network_info: SlowMetricsConfig,
|
||||
#[serde(default)]
|
||||
pub hardware_info: SlowMetricsConfig,
|
||||
#[serde(default = "default_slow_time")]
|
||||
pub slow_daily_time: String, // "HH:MM"
|
||||
```
|
||||
|
||||
```rust
|
||||
fn default_slow_time() -> String { "03:00".to_string() }
|
||||
```
|
||||
|
||||
- [ ] **Vérifier** : `cargo check --manifest-path agent/Cargo.toml`
|
||||
|
||||
- [ ] **Commit** :
|
||||
```bash
|
||||
git add agent/src/payload.rs agent/src/config.rs
|
||||
git commit -m "feat(agent): structs NetworkInterface + HardwareInfo + config slow_daily_time"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2 — Agent : module network_info
|
||||
|
||||
**Files:**
|
||||
- Create: `agent/src/metrics/network_info.rs`
|
||||
- Modify: `agent/src/metrics/mod.rs`
|
||||
|
||||
- [ ] **Créer `agent/src/metrics/network_info.rs`** :
|
||||
|
||||
```rust
|
||||
use std::mem::MaybeUninit;
|
||||
|
||||
fn local_hhmm() -> (u32, u32) {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
let mut tm = MaybeUninit::<libc::tm>::uninit();
|
||||
unsafe { libc::localtime_r(&now, tm.as_mut_ptr()) };
|
||||
let tm = unsafe { tm.assume_init() };
|
||||
(tm.tm_hour as u32, tm.tm_min as u32)
|
||||
}
|
||||
|
||||
pub fn current_hhmm() -> (u32, u32) { local_hhmm() }
|
||||
|
||||
fn is_physical(name: &str) -> bool {
|
||||
// Exclure loopback, virtuels, docker, bridges
|
||||
if name == "lo" { return false; }
|
||||
for prefix in &["veth", "docker", "br-", "virbr", "vir", "tun", "tap", "dummy"] {
|
||||
if name.starts_with(prefix) { return false; }
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn read_sysfs(iface: &str, file: &str) -> Option<String> {
|
||||
std::fs::read_to_string(format!("/sys/class/net/{}/{}", iface, file))
|
||||
.ok()
|
||||
.map(|s| s.trim().to_string())
|
||||
}
|
||||
|
||||
fn is_wifi(name: &str) -> bool {
|
||||
std::path::Path::new(&format!("/sys/class/net/{}/wireless", name)).exists()
|
||||
}
|
||||
|
||||
fn wol_status(name: &str) -> Option<bool> {
|
||||
let out = std::process::Command::new("ethtool")
|
||||
.arg(name).output().ok()?;
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.starts_with("Wake-on:") {
|
||||
let val = t.split(':').nth(1)?.trim();
|
||||
return Some(val != "d" && !val.is_empty());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn iperf_mbps(server_ip: &str) -> Option<f64> {
|
||||
// Vérifier que iperf3 est disponible
|
||||
if !std::process::Command::new("which").arg("iperf3")
|
||||
.output().map(|o| o.status.success()).unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
let out = std::process::Command::new("iperf3")
|
||||
.args(["-c", server_ip, "-J", "-t", "5", "-P", "1"])
|
||||
.output().ok()?;
|
||||
let json = String::from_utf8_lossy(&out.stdout);
|
||||
// parser "end" > "sum_received" > "bits_per_second"
|
||||
let v: serde_json::Value = serde_json::from_str(&json).ok()?;
|
||||
let bps = v["end"]["sum_received"]["bits_per_second"].as_f64()?;
|
||||
Some(bps / 1_000_000.0)
|
||||
}
|
||||
|
||||
pub fn collect(server_ip: &str) -> Vec<crate::payload::NetworkInterface> {
|
||||
let entries = match std::fs::read_dir("/sys/class/net") {
|
||||
Ok(e) => e, Err(_) => return vec![],
|
||||
};
|
||||
let mut ifaces: Vec<String> = entries
|
||||
.flatten()
|
||||
.map(|e| e.file_name().into_string().unwrap_or_default())
|
||||
.filter(|n| is_physical(n))
|
||||
.collect();
|
||||
ifaces.sort();
|
||||
|
||||
// Lancer iperf une seule fois pour tous (pas par interface)
|
||||
let iperf = iperf_mbps(server_ip);
|
||||
|
||||
ifaces.iter().map(|name| {
|
||||
let speed = read_sysfs(name, "speed")
|
||||
.and_then(|s| s.parse::<i64>().ok())
|
||||
.filter(|&v| v > 0);
|
||||
let mac = read_sysfs(name, "address").unwrap_or_default();
|
||||
crate::payload::NetworkInterface {
|
||||
name: name.clone(),
|
||||
if_type: if is_wifi(name) { "wifi".to_string() } else { "ethernet".to_string() },
|
||||
speed_mbps: speed,
|
||||
mac,
|
||||
wol: if is_wifi(name) { None } else { wol_status(name) },
|
||||
iperf_mbps: iperf,
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Ajouter dans `agent/src/metrics/mod.rs`** : `pub mod network_info;`
|
||||
|
||||
- [ ] **Vérifier** : `cargo check --manifest-path agent/Cargo.toml`
|
||||
|
||||
- [ ] **Commit** :
|
||||
```bash
|
||||
git add agent/src/metrics/network_info.rs agent/src/metrics/mod.rs
|
||||
git commit -m "feat(agent): module network_info (interfaces, WoL, iperf3)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3 — Agent : module hardware
|
||||
|
||||
**Files:**
|
||||
- Create: `agent/src/metrics/hardware.rs`
|
||||
- Modify: `agent/src/metrics/mod.rs`
|
||||
|
||||
- [ ] **Créer `agent/src/metrics/hardware.rs`** :
|
||||
|
||||
```rust
|
||||
fn run_dmidecode(type_num: u8) -> String {
|
||||
std::process::Command::new("dmidecode")
|
||||
.args(["-t", &type_num.to_string()])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn extract_field<'a>(text: &'a str, key: &str) -> Option<String> {
|
||||
for line in text.lines() {
|
||||
let t = line.trim();
|
||||
if t.starts_with(key) {
|
||||
let val = t[key.len()..].trim().trim_start_matches(':').trim();
|
||||
if !val.is_empty() && val != "Not Specified" && val != "Unknown" {
|
||||
return Some(val.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn is_available() -> bool {
|
||||
std::process::Command::new("which").arg("dmidecode")
|
||||
.output().map(|o| o.status.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn collect() -> Option<crate::payload::HardwareInfo> {
|
||||
if !is_available() { return None; }
|
||||
|
||||
// Type 2 = Baseboard, Type 4 = Processor, Type 17 = Memory Device
|
||||
let board = run_dmidecode(2);
|
||||
let cpu = run_dmidecode(4);
|
||||
let mem = run_dmidecode(17);
|
||||
|
||||
let mut slots_total: i64 = 0;
|
||||
let mut slots_used: i64 = 0;
|
||||
let mut ram_type: Option<String> = None;
|
||||
let mut ram_speed: Option<i64> = None;
|
||||
|
||||
// Compter les slots mémoire
|
||||
for block in mem.split("\n\n") {
|
||||
if block.contains("Memory Device") {
|
||||
slots_total += 1;
|
||||
if let Some(size) = extract_field(block, "Size") {
|
||||
if !size.contains("No Module") {
|
||||
slots_used += 1;
|
||||
}
|
||||
}
|
||||
if ram_type.is_none() {
|
||||
ram_type = extract_field(block, "Type");
|
||||
}
|
||||
if ram_speed.is_none() {
|
||||
if let Some(spd) = extract_field(block, "Speed") {
|
||||
// "3200 MT/s" → 3200
|
||||
ram_speed = spd.split_whitespace().next()
|
||||
.and_then(|s| s.parse().ok());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some(crate::payload::HardwareInfo {
|
||||
motherboard_vendor: extract_field(&board, "Manufacturer"),
|
||||
motherboard_model: extract_field(&board, "Product Name"),
|
||||
cpu_model: extract_field(&cpu, "Version"),
|
||||
ram_type,
|
||||
ram_speed_mhz: ram_speed,
|
||||
ram_slots_used: if slots_total > 0 { Some(slots_used) } else { None },
|
||||
ram_slots_total: if slots_total > 0 { Some(slots_total) } else { None },
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Ajouter dans `agent/src/metrics/mod.rs`** : `pub mod hardware;`
|
||||
|
||||
- [ ] **Vérifier** : `cargo check --manifest-path agent/Cargo.toml`
|
||||
|
||||
- [ ] **Commit** :
|
||||
```bash
|
||||
git add agent/src/metrics/hardware.rs agent/src/metrics/mod.rs
|
||||
git commit -m "feat(agent): module hardware (dmidecode — carte mère, CPU, RAM)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4 — Agent : scheduler + intégration main.rs + install.sh + version
|
||||
|
||||
**Files:**
|
||||
- Modify: `agent/src/main.rs`
|
||||
- Modify: `agent/Cargo.toml`
|
||||
- Modify: `deploy/install.sh`
|
||||
|
||||
- [ ] **Bump version** dans `agent/Cargo.toml` : `0.1.5` → `0.1.6`
|
||||
|
||||
- [ ] **Ajouter dans `deploy/install.sh`** les paquets `iperf3` et `dmidecode` :
|
||||
```bash
|
||||
for pkg in curl python3 smartmontools ethtool iperf3 dmidecode; do
|
||||
```
|
||||
|
||||
- [ ] **Ajouter dans `agent/src/main.rs`** le scheduler slow + appels modules. Après les variables `first_slow` / `last_slow`, ajouter :
|
||||
|
||||
```rust
|
||||
// Scheduler métriques lentes (startup + 1×/jour à l'heure configurée)
|
||||
let slow_time: (u32, u32) = {
|
||||
let parts: Vec<&str> = cfg.metrics.slow_daily_time.split(':').collect();
|
||||
let h = parts.get(0).and_then(|s| s.parse().ok()).unwrap_or(3);
|
||||
let m = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
(h, m)
|
||||
};
|
||||
let mut slow_daily_done = false;
|
||||
let mut slow_last_date: u32 = 0; // tm_yday pour détecter changement de jour
|
||||
|
||||
// Collecte immédiate au démarrage
|
||||
if cfg.metrics.network_info.udp || cfg.metrics.network_info.mqtt {
|
||||
let ni = metrics::network_info::collect(&cfg.server.ip);
|
||||
if !ni.is_empty() { m.network_info = Some(ni); }
|
||||
}
|
||||
if cfg.metrics.hardware_info.udp || cfg.metrics.hardware_info.mqtt {
|
||||
m.hardware_info = metrics::hardware::collect();
|
||||
}
|
||||
```
|
||||
|
||||
Dans la boucle principale, ajouter la vérification de l'heure après le bloc `first_slow` :
|
||||
|
||||
```rust
|
||||
// Métriques lentes quotidiennes
|
||||
{
|
||||
use std::mem::MaybeUninit;
|
||||
let now_ts = std::time::SystemTime::now()
|
||||
.duration_since(std::time::SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default().as_secs() as i64;
|
||||
let mut tm = MaybeUninit::<libc::tm>::uninit();
|
||||
unsafe { libc::localtime_r(&now_ts, tm.as_mut_ptr()) };
|
||||
let tm = unsafe { tm.assume_init() };
|
||||
let (cur_h, cur_m) = (tm.tm_hour as u32, tm.tm_min as u32);
|
||||
let cur_yday = tm.tm_yday as u32;
|
||||
|
||||
if cur_yday != slow_last_date {
|
||||
slow_last_date = cur_yday;
|
||||
slow_daily_done = false;
|
||||
}
|
||||
if !slow_daily_done && cur_h == slow_time.0 && cur_m == slow_time.1 {
|
||||
slow_daily_done = true;
|
||||
if cfg.metrics.network_info.udp || cfg.metrics.network_info.mqtt {
|
||||
let ni = metrics::network_info::collect(&cfg.server.ip);
|
||||
if !ni.is_empty() { m.network_info = Some(ni); }
|
||||
}
|
||||
if cfg.metrics.hardware_info.udp || cfg.metrics.hardware_info.mqtt {
|
||||
m.hardware_info = metrics::hardware::collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Vérifier** : `cargo check --manifest-path agent/Cargo.toml`
|
||||
|
||||
- [ ] **Commit** :
|
||||
```bash
|
||||
git add agent/src/main.rs agent/Cargo.toml deploy/install.sh
|
||||
git commit -m "feat(agent v0.1.6): scheduler slow metrics + réseau + hardware + iperf3/dmidecode dans install.sh"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5 — Serveur : modèles Go + migrations DB + stockage
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/models/models.go`
|
||||
- Modify: `server/db/db.go`
|
||||
|
||||
- [ ] **Ajouter dans `server/models/models.go`** :
|
||||
|
||||
```go
|
||||
type NetworkInterface struct {
|
||||
Name string `json:"name"`
|
||||
IfType string `json:"if_type"`
|
||||
SpeedMbps *int64 `json:"speed_mbps"`
|
||||
MAC string `json:"mac"`
|
||||
WoL *bool `json:"wol"`
|
||||
IperfMbps *float64 `json:"iperf_mbps"`
|
||||
}
|
||||
|
||||
type HardwareInfo struct {
|
||||
MotherboardVendor *string `json:"motherboard_vendor"`
|
||||
MotherboardModel *string `json:"motherboard_model"`
|
||||
CPUModel *string `json:"cpu_model"`
|
||||
RAMType *string `json:"ram_type"`
|
||||
RAMSpeedMHz *int64 `json:"ram_speed_mhz"`
|
||||
RAMSlotsUsed *int64 `json:"ram_slots_used"`
|
||||
RAMSlotsTotal *int64 `json:"ram_slots_total"`
|
||||
}
|
||||
```
|
||||
|
||||
Dans `AgentMetrics`, ajouter :
|
||||
```go
|
||||
NetworkInfo []NetworkInterface `json:"network_info"`
|
||||
HardwareInfo *HardwareInfo `json:"hardware_info"`
|
||||
```
|
||||
|
||||
Dans `Agent`, ajouter :
|
||||
```go
|
||||
NetworkInfo []NetworkInterface `json:"network_info,omitempty"`
|
||||
HardwareInfo *HardwareInfo `json:"hardware_info,omitempty"`
|
||||
```
|
||||
|
||||
- [ ] **Dans `server/db/db.go`** — migrations :
|
||||
|
||||
Dans `migrate()`, ajouter :
|
||||
```go
|
||||
_, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN network_info_json TEXT`)
|
||||
_, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN hardware_info_json TEXT`)
|
||||
```
|
||||
|
||||
- [ ] **Dans `UpsertAgent()`** — stocker les données lentes si présentes :
|
||||
|
||||
```go
|
||||
func (d *DB) UpsertAgent(m *models.AgentMetrics) error {
|
||||
ts := time.Now().Unix()
|
||||
var netJSON, hwJSON interface{}
|
||||
if len(m.NetworkInfo) > 0 {
|
||||
if b, err := json.Marshal(m.NetworkInfo); err == nil {
|
||||
netJSON = string(b)
|
||||
}
|
||||
}
|
||||
if m.HardwareInfo != nil {
|
||||
if b, err := json.Marshal(m.HardwareInfo); err == nil {
|
||||
hwJSON = string(b)
|
||||
}
|
||||
}
|
||||
_, err := d.conn.Exec(`
|
||||
INSERT INTO agents (id, hostname, ip, status, last_seen, version)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
ip=excluded.ip, status=excluded.status, last_seen=excluded.last_seen,
|
||||
version=CASE WHEN excluded.version != '' THEN excluded.version ELSE version END,
|
||||
network_info_json=CASE WHEN ?7 IS NOT NULL THEN ?7 ELSE network_info_json END,
|
||||
hardware_info_json=CASE WHEN ?8 IS NOT NULL THEN ?8 ELSE hardware_info_json END`,
|
||||
m.Hostname, m.Hostname, m.IP, m.Status, ts, m.Version, netJSON, hwJSON)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Dans `GetAgents()`** — lire et désérialiser les colonnes JSON :
|
||||
|
||||
```go
|
||||
func (d *DB) GetAgents() ([]models.Agent, error) {
|
||||
rows, err := d.conn.Query(`SELECT id, hostname, ip, status, last_seen, version,
|
||||
network_info_json, hardware_info_json FROM agents`)
|
||||
// ...
|
||||
var netJSON, hwJSON *string
|
||||
if err := rows.Scan(&a.ID, &a.Hostname, &a.IP, &a.Status, &a.LastSeen, &a.Version,
|
||||
&netJSON, &hwJSON); err != nil { ... }
|
||||
if netJSON != nil { _ = json.Unmarshal([]byte(*netJSON), &a.NetworkInfo) }
|
||||
if hwJSON != nil { _ = json.Unmarshal([]byte(*hwJSON), &a.HardwareInfo) }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Vérifier** : `cd server && go build ./...`
|
||||
|
||||
- [ ] **Commit** :
|
||||
```bash
|
||||
git add server/models/models.go server/db/db.go
|
||||
git commit -m "feat(server): NetworkInterface + HardwareInfo — migration DB + stockage agents"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6 — Serveur : API GET /api/agents/{id} + docker-compose iperf3
|
||||
|
||||
**Files:**
|
||||
- Modify: `server/handlers/agents.go`
|
||||
- Modify: `server/main.go`
|
||||
- Modify: `server/docker-compose.yml`
|
||||
|
||||
- [ ] **Ajouter dans `server/handlers/agents.go`** le handler single agent :
|
||||
|
||||
```go
|
||||
func AgentDetailHandler(database *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
if len(parts) < 3 { http.Error(w, "invalid path", 400); return }
|
||||
agentID := parts[2]
|
||||
agents, err := database.GetAgents()
|
||||
if err != nil { http.Error(w, err.Error(), 500); return }
|
||||
for _, a := range agents {
|
||||
if a.ID == agentID {
|
||||
a.LastMetrics, _ = database.GetLastMetrics(agentID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(a)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Dans `server/main.go`** — ajouter la route dans le switch `/api/agents/` :
|
||||
|
||||
```go
|
||||
case r.Method == http.MethodGet && !strings.HasSuffix(r.URL.Path, "/"):
|
||||
handlers.AgentDetailHandler(database)(w, r)
|
||||
```
|
||||
|
||||
- [ ] **Dans `server/docker-compose.yml`** — ajouter le service iperf3 :
|
||||
|
||||
```yaml
|
||||
iperf3:
|
||||
image: ${IPERF3_IMAGE:-public.ecr.aws/docker/library/networkstatic/iperf3:latest}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
command: ["-s"]
|
||||
ports:
|
||||
- "5201:5201"
|
||||
```
|
||||
|
||||
- [ ] **Vérifier** : `cd server && go build ./...`
|
||||
|
||||
- [ ] **Commit** :
|
||||
```bash
|
||||
git add server/handlers/agents.go server/main.go server/docker-compose.yml
|
||||
git commit -m "feat(server): GET /api/agents/{id} + service iperf3 dans compose"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7 — Dashboard : section réseau dans popup détail
|
||||
|
||||
**Files:**
|
||||
- Modify: `dashboard/js/popups.js`
|
||||
- Modify: `dashboard/css/app.css`
|
||||
|
||||
- [ ] **Ajouter CSS** dans `app.css` pour la section réseau :
|
||||
|
||||
```css
|
||||
.net-table{display:flex;flex-direction:column;gap:4px}
|
||||
.net-row{display:grid;grid-template-columns:auto 1fr 80px 120px 60px 90px;
|
||||
align-items:center;gap:8px;padding:6px 10px;
|
||||
background:var(--bg-3);border-radius:6px;border:1px solid var(--border-1);
|
||||
font-family:var(--font-terminal);font-size:10px}
|
||||
.net-row:first-child{background:var(--bg-4);font-size:9px;color:var(--ink-4);letter-spacing:.06em}
|
||||
.net-val{font-family:var(--font-mono);font-size:11px;color:var(--ink-2)}
|
||||
.hw-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
|
||||
```
|
||||
|
||||
- [ ] **Dans `popups.js`**, après la section STOCKAGE dans `pop-body`, ajouter les sections réseau et hardware. Construire les variables HTML :
|
||||
|
||||
```javascript
|
||||
const netSection = entry?.agent?.network_info?.length > 0
|
||||
? /* tableau des interfaces */ ...
|
||||
: '';
|
||||
|
||||
const hwSection = entry?.agent?.hardware_info
|
||||
? /* grille hardware */ ...
|
||||
: '';
|
||||
```
|
||||
|
||||
Insérer `${netSection}${hwSection}` avant la section INFORMATIONS.
|
||||
|
||||
- [ ] **Commit** :
|
||||
```bash
|
||||
git add dashboard/js/popups.js dashboard/css/app.css
|
||||
git commit -m "feat(dashboard): sections réseau et hardware dans popup détail"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8 — Dashboard : font-size global
|
||||
|
||||
**Files:**
|
||||
- Modify: `dashboard/js/app.js`
|
||||
- Modify: `dashboard/css/app.css`
|
||||
|
||||
- [ ] **Dans `app.js`**, changer l'application du font-size : appliquer sur `html` (root) au lieu de `body` :
|
||||
|
||||
```javascript
|
||||
if (_serverConfig.font_size) {
|
||||
document.documentElement.style.fontSize = _serverConfig.font_size + 'px';
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Dans `app.css`**, vérifier que les éléments clés utilisent `rem` pour les tailles de police principales. Ajouter la règle de base sur `html` :
|
||||
|
||||
```css
|
||||
html { font-size: 13px; } /* valeur par défaut, écrasée par JS */
|
||||
```
|
||||
|
||||
Les éléments qui utilisent déjà des tailles en `px` absolues seront progressivement mis à l'échelle via ce mécanisme. Ceux qui héritent (`font-size: inherit`) bénéficieront automatiquement.
|
||||
|
||||
- [ ] **Commit** :
|
||||
```bash
|
||||
git add dashboard/js/app.js dashboard/css/app.css
|
||||
git commit -m "fix(dashboard): font-size global appliqué sur html root"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9 — Release et déploiement
|
||||
|
||||
- [ ] **Rebuild agent** : `cargo build --release --manifest-path agent/Cargo.toml`
|
||||
- [ ] **Copier binaires** dans `dist/`
|
||||
- [ ] **Rebuild Docker** : `cd server && docker compose up -d --build`
|
||||
- [ ] **Redéployer l'agent** via `install.sh` sur chaque VM cible
|
||||
- [ ] **Push final** : `git push`
|
||||
@@ -1,14 +1,14 @@
|
||||
FROM golang:1.22-alpine AS builder
|
||||
ARG GO_IMAGE=public.ecr.aws/docker/library/golang:1.25-alpine
|
||||
FROM ${GO_IMAGE} AS builder
|
||||
WORKDIR /app
|
||||
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 .
|
||||
FROM scratch
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=builder /app/nanometrics-server /nanometrics-server
|
||||
VOLUME /data
|
||||
EXPOSE 8080 9999/udp
|
||||
CMD ["./nanometrics-server"]
|
||||
CMD ["/nanometrics-server"]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Dockerfile de dev : utilise le binaire pré-compilé localement (pas de pull Docker Hub)
|
||||
FROM nginx:alpine
|
||||
ARG NGINX_IMAGE=public.ecr.aws/docker/library/nginx:alpine
|
||||
FROM ${NGINX_IMAGE}
|
||||
COPY nanometrics-server /app/nanometrics-server
|
||||
WORKDIR /app
|
||||
VOLUME /data
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
ARG ALPINE_IMAGE=public.ecr.aws/docker/library/alpine:latest
|
||||
FROM ${ALPINE_IMAGE}
|
||||
RUN apk add --no-cache iperf3
|
||||
EXPOSE 5201
|
||||
ENTRYPOINT ["iperf3"]
|
||||
@@ -20,7 +20,7 @@ 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
|
||||
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,
|
||||
@@ -57,54 +57,76 @@ func Open(path string) (*DB, error) {
|
||||
}
|
||||
|
||||
func (d *DB) migrate() error {
|
||||
_, err := d.conn.Exec(schema)
|
||||
return err
|
||||
if _, err := d.conn.Exec(schema); err != nil {
|
||||
return err
|
||||
}
|
||||
// Migrations additives — ignorées si la colonne existe déjà
|
||||
_, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN version TEXT NOT NULL DEFAULT ''`)
|
||||
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_passed INTEGER`)
|
||||
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_temp INTEGER`)
|
||||
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_realloc INTEGER`)
|
||||
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_hours INTEGER`)
|
||||
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_wear INTEGER`)
|
||||
_, _ = d.conn.Exec(`ALTER TABLE metrics ADD COLUMN smart_json TEXT`)
|
||||
_, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN network_info_json TEXT`)
|
||||
_, _ = d.conn.Exec(`ALTER TABLE agents ADD COLUMN hardware_info_json TEXT`)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DB) Close() { _ = d.conn.Close() }
|
||||
|
||||
func (d *DB) UpsertAgent(m *models.AgentMetrics) error {
|
||||
ts := time.Now().Unix()
|
||||
var netJSON, hwJSON interface{}
|
||||
if len(m.NetworkInfo) > 0 {
|
||||
if b, err := json.Marshal(m.NetworkInfo); err == nil {
|
||||
netJSON = string(b)
|
||||
}
|
||||
}
|
||||
if m.HardwareInfo != nil {
|
||||
if b, err := json.Marshal(m.HardwareInfo); err == nil {
|
||||
hwJSON = string(b)
|
||||
}
|
||||
}
|
||||
_, err := d.conn.Exec(`
|
||||
INSERT INTO agents (id, hostname, ip, status, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO agents (id, hostname, ip, status, last_seen, version,
|
||||
network_info_json, hardware_info_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
ip=excluded.ip, status=excluded.status, last_seen=excluded.last_seen`,
|
||||
m.Hostname, m.Hostname, m.IP, m.Status, ts)
|
||||
ip=excluded.ip, status=excluded.status, last_seen=excluded.last_seen,
|
||||
version=CASE WHEN excluded.version != '' THEN excluded.version ELSE version END,
|
||||
network_info_json=CASE WHEN excluded.network_info_json IS NOT NULL THEN excluded.network_info_json ELSE network_info_json END,
|
||||
hardware_info_json=CASE WHEN excluded.hardware_info_json IS NOT NULL THEN excluded.hardware_info_json ELSE hardware_info_json END`,
|
||||
m.Hostname, m.Hostname, m.IP, m.Status, ts, m.Version, netJSON, hwJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) InsertMetrics(m *models.AgentMetrics) error {
|
||||
ts := time.Now().Unix()
|
||||
var smartPassed, smartTemp, smartRealloc, smartHours, smartWear interface{}
|
||||
if m.Smart != nil {
|
||||
b := 0
|
||||
if m.Smart.Passed {
|
||||
b = 1
|
||||
var smartJSON interface{}
|
||||
if len(m.Smart) > 0 {
|
||||
if b, err := json.Marshal(m.Smart); err == nil {
|
||||
smartJSON = string(b)
|
||||
}
|
||||
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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
smart_json)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
m.Hostname, ts,
|
||||
m.CPUPercent, m.MemoryUsed, m.MemoryFree, m.MemoryTotal,
|
||||
m.HDDUsed, m.HDDFree, m.HDDTotal,
|
||||
m.Uptime, m.NetworkRX, m.NetworkTX, m.Temperature,
|
||||
smartPassed, smartTemp, smartRealloc, smartHours, smartWear)
|
||||
smartJSON)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *DB) GetAgents() ([]models.Agent, error) {
|
||||
rows, err := d.conn.Query(`SELECT id, hostname, ip, status, last_seen FROM agents`)
|
||||
rows, err := d.conn.Query(`SELECT id, hostname, ip, status, last_seen, version,
|
||||
network_info_json, hardware_info_json FROM agents`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -112,9 +134,17 @@ func (d *DB) GetAgents() ([]models.Agent, error) {
|
||||
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); err != nil {
|
||||
var netJSON, hwJSON *string
|
||||
if err := rows.Scan(&a.ID, &a.Hostname, &a.IP, &a.Status, &a.LastSeen, &a.Version,
|
||||
&netJSON, &hwJSON); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if netJSON != nil {
|
||||
_ = json.Unmarshal([]byte(*netJSON), &a.NetworkInfo)
|
||||
}
|
||||
if hwJSON != nil {
|
||||
_ = json.Unmarshal([]byte(*hwJSON), &a.HardwareInfo)
|
||||
}
|
||||
agents = append(agents, a)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -123,6 +153,59 @@ func (d *DB) GetAgents() ([]models.Agent, error) {
|
||||
return agents, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetLastMetrics(agentID string) (*models.AgentMetrics, error) {
|
||||
var cpu, temperature *float64
|
||||
var memUsed, memFree, memTotal, hddUsed, hddFree, hddTotal *int64
|
||||
var uptime, netRX, netTX *int64
|
||||
var smartJSON *string
|
||||
|
||||
err := d.conn.QueryRow(`
|
||||
SELECT
|
||||
(SELECT cpu_percent FROM metrics WHERE agent_id=? AND cpu_percent IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
||||
(SELECT memory_used FROM metrics WHERE agent_id=? AND memory_used IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
||||
(SELECT memory_free FROM metrics WHERE agent_id=? AND memory_free IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
||||
(SELECT memory_total FROM metrics WHERE agent_id=? AND memory_total IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
||||
(SELECT hdd_used FROM metrics WHERE agent_id=? AND hdd_used IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
||||
(SELECT hdd_free FROM metrics WHERE agent_id=? AND hdd_free IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
||||
(SELECT hdd_total FROM metrics WHERE agent_id=? AND hdd_total IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
||||
(SELECT uptime FROM metrics WHERE agent_id=? AND uptime IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
||||
(SELECT network_rx FROM metrics WHERE agent_id=? AND network_rx IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
||||
(SELECT network_tx FROM metrics WHERE agent_id=? AND network_tx IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
||||
(SELECT temperature FROM metrics WHERE agent_id=? AND temperature IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
||||
(SELECT smart_json FROM metrics WHERE agent_id=? AND smart_json IS NOT NULL ORDER BY ts DESC LIMIT 1)`,
|
||||
agentID, agentID, agentID, agentID,
|
||||
agentID, agentID, agentID,
|
||||
agentID, agentID, agentID, agentID,
|
||||
agentID).
|
||||
Scan(&cpu, &memUsed, &memFree, &memTotal,
|
||||
&hddUsed, &hddFree, &hddTotal,
|
||||
&uptime, &netRX, &netTX, &temperature,
|
||||
&smartJSON)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := &models.AgentMetrics{
|
||||
CPUPercent: cpu,
|
||||
MemoryUsed: memUsed,
|
||||
MemoryFree: memFree,
|
||||
MemoryTotal: memTotal,
|
||||
HDDUsed: hddUsed,
|
||||
HDDFree: hddFree,
|
||||
HDDTotal: hddTotal,
|
||||
Uptime: uptime,
|
||||
NetworkRX: netRX,
|
||||
NetworkTX: netTX,
|
||||
Temperature: temperature,
|
||||
}
|
||||
if smartJSON != nil {
|
||||
_ = json.Unmarshal([]byte(*smartJSON), &m.Smart)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (d *DB) GetMetricsHistory(agentID string, from, to int64) ([]map[string]interface{}, error) {
|
||||
rows, err := d.conn.Query(`
|
||||
SELECT ts, cpu_percent, memory_used, memory_total, hdd_used, hdd_total
|
||||
@@ -238,6 +321,20 @@ func (d *DB) MarkOffline(timeoutSec int64) error {
|
||||
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
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
- "9999:9999/udp"
|
||||
|
||||
dashboard:
|
||||
image: nginx:alpine
|
||||
image: ${NGINX_IMAGE:-public.ecr.aws/docker/library/nginx:alpine}
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
server:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
pull: false
|
||||
args:
|
||||
GO_IMAGE: ${GO_IMAGE:-public.ecr.aws/docker/library/golang:1.25-alpine}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
UDP_ADDR: "0.0.0.0:9999"
|
||||
@@ -15,7 +18,8 @@ services:
|
||||
- "9999:9999/udp"
|
||||
|
||||
dashboard:
|
||||
image: nginx:alpine
|
||||
image: ${NGINX_IMAGE:-public.ecr.aws/docker/library/nginx:alpine}
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
@@ -25,5 +29,17 @@ services:
|
||||
depends_on:
|
||||
- server
|
||||
|
||||
iperf3:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.iperf3
|
||||
args:
|
||||
ALPINE_IMAGE: ${ALPINE_IMAGE:-public.ecr.aws/docker/library/alpine:latest}
|
||||
restart: unless-stopped
|
||||
command: ["-s"]
|
||||
ports:
|
||||
- "5202:5201/tcp"
|
||||
- "5202:5201/udp"
|
||||
|
||||
volumes:
|
||||
nanometrics_data:
|
||||
|
||||
@@ -3,8 +3,10 @@ package handlers
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/user/nanometrics/server/db"
|
||||
"github.com/user/nanometrics/server/models"
|
||||
)
|
||||
|
||||
func AgentsHandler(database *db.DB) http.HandlerFunc {
|
||||
@@ -14,7 +16,52 @@ func AgentsHandler(database *db.DB) http.HandlerFunc {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
for i := range agents {
|
||||
agents[i].LastMetrics, _ = database.GetLastMetrics(agents[i].ID)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(agents)
|
||||
}
|
||||
}
|
||||
|
||||
func AgentDetailHandler(database *db.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
if len(parts) < 3 {
|
||||
http.Error(w, "invalid path", 400)
|
||||
return
|
||||
}
|
||||
agentID := parts[2]
|
||||
agents, err := database.GetAgents()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
for _, a := range agents {
|
||||
if a.ID == agentID {
|
||||
a.LastMetrics, _ = database.GetLastMetrics(agentID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(a)
|
||||
return
|
||||
}
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func DeleteAgentHandler(database *db.DB, broadcast func(interface{})) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
|
||||
if len(parts) < 3 {
|
||||
http.Error(w, "invalid path", 400)
|
||||
return
|
||||
}
|
||||
agentID := parts[2]
|
||||
if err := database.DeleteAgent(agentID); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
broadcast(models.WSMessage{Type: "agent_removed", AgentID: agentID})
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func AgentConfigHandler(database *db.DB, pushConfig func(agentID string, cfg *mo
|
||||
return
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = &models.AgentConfig{}
|
||||
cfg = models.DefaultAgentConfig()
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(cfg)
|
||||
|
||||
@@ -3,6 +3,7 @@ package handlers
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
_ "image/gif"
|
||||
_ "image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
"github.com/user/nanometrics/server/db"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
const maxIconSize = 128
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
ws "github.com/user/nanometrics/server/websocket"
|
||||
)
|
||||
|
||||
const serverVersion = "0.1.1"
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
@@ -92,6 +94,7 @@ func main() {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
stats.Version = serverVersion
|
||||
hub.Broadcast(models.WSMessage{Type: "server_stats", Data: stats})
|
||||
}
|
||||
}()
|
||||
@@ -110,6 +113,10 @@ func main() {
|
||||
handlers.IconUploadHandler(database)(w, r)
|
||||
case endsWith(r.URL.Path, "/icon") && r.Method == http.MethodGet:
|
||||
handlers.IconGetHandler(database)(w, r)
|
||||
case r.Method == http.MethodDelete:
|
||||
handlers.DeleteAgentHandler(database, hub.Broadcast)(w, r)
|
||||
case r.Method == http.MethodGet:
|
||||
handlers.AgentDetailHandler(database)(w, r)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
|
||||
@@ -1,24 +1,47 @@
|
||||
package models
|
||||
|
||||
type AgentMetrics struct {
|
||||
Hostname string `json:"hostname"`
|
||||
IP string `json:"ip"`
|
||||
Status string `json:"status"`
|
||||
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"`
|
||||
Hostname string `json:"hostname"`
|
||||
IP string `json:"ip"`
|
||||
Status string `json:"status"`
|
||||
Version string `json:"version"`
|
||||
CPUPercent *float64 `json:"cpu_percent"`
|
||||
MemoryUsed *int64 `json:"memory_used"`
|
||||
MemoryFree *int64 `json:"memory_free"`
|
||||
MemoryTotal *int64 `json:"memory_total"`
|
||||
HDDUsed *int64 `json:"hdd_used"`
|
||||
HDDFree *int64 `json:"hdd_free"`
|
||||
HDDTotal *int64 `json:"hdd_total"`
|
||||
Uptime *int64 `json:"uptime"`
|
||||
NetworkRX *int64 `json:"network_rx"`
|
||||
NetworkTX *int64 `json:"network_tx"`
|
||||
Temperature *float64 `json:"temperature"`
|
||||
Smart []SmartMetrics `json:"smart"`
|
||||
NetworkInfo []NetworkInterface `json:"network_info"`
|
||||
HardwareInfo *HardwareInfo `json:"hardware_info"`
|
||||
}
|
||||
|
||||
type NetworkInterface struct {
|
||||
Name string `json:"name"`
|
||||
IfType string `json:"if_type"`
|
||||
SpeedMbps *int64 `json:"speed_mbps"`
|
||||
MAC string `json:"mac"`
|
||||
WoL *bool `json:"wol"`
|
||||
IperfMbps *float64 `json:"iperf_mbps"`
|
||||
}
|
||||
|
||||
type HardwareInfo struct {
|
||||
MotherboardVendor *string `json:"motherboard_vendor"`
|
||||
MotherboardModel *string `json:"motherboard_model"`
|
||||
CPUModel *string `json:"cpu_model"`
|
||||
RAMType *string `json:"ram_type"`
|
||||
RAMSpeedMHz *int64 `json:"ram_speed_mhz"`
|
||||
RAMSlotsUsed *int64 `json:"ram_slots_used"`
|
||||
RAMSlotsTotal *int64 `json:"ram_slots_total"`
|
||||
}
|
||||
|
||||
type SmartMetrics struct {
|
||||
Device string `json:"device"`
|
||||
Passed bool `json:"passed"`
|
||||
Temperature *int64 `json:"temperature"`
|
||||
ReallocatedSectors *int64 `json:"reallocated_sectors"`
|
||||
@@ -27,12 +50,15 @@ type SmartMetrics struct {
|
||||
}
|
||||
|
||||
type Agent struct {
|
||||
ID string `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
IP string `json:"ip"`
|
||||
Status string `json:"status"`
|
||||
LastSeen int64 `json:"last_seen"`
|
||||
LastMetrics *AgentMetrics `json:"last_metrics,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Hostname string `json:"hostname"`
|
||||
IP string `json:"ip"`
|
||||
Status string `json:"status"`
|
||||
LastSeen int64 `json:"last_seen"`
|
||||
Version string `json:"version,omitempty"`
|
||||
LastMetrics *AgentMetrics `json:"last_metrics,omitempty"`
|
||||
NetworkInfo []NetworkInterface `json:"network_info,omitempty"`
|
||||
HardwareInfo *HardwareInfo `json:"hardware_info,omitempty"`
|
||||
}
|
||||
|
||||
type AgentConfig struct {
|
||||
@@ -75,17 +101,32 @@ type MetricProto struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
TileMinWidth int `json:"tile_min_width"`
|
||||
FontSize int `json:"font_size"`
|
||||
WarnCPU int `json:"warn_cpu"`
|
||||
ErrCPU int `json:"err_cpu"`
|
||||
WarnDisk int `json:"warn_disk"`
|
||||
RetentionDays int `json:"retention_days"`
|
||||
ChartDurationMin int `json:"chart_duration_min"`
|
||||
HideOffline bool `json:"hide_offline"`
|
||||
Notifications bool `json:"notifications"`
|
||||
PopupDetailW int `json:"popup_detail_w"`
|
||||
PopupDetailH int `json:"popup_detail_h"`
|
||||
GaugeType string `json:"gauge_type"`
|
||||
}
|
||||
|
||||
func DefaultAgentConfig() *AgentConfig {
|
||||
on := MetricProto{UDP: true, MQTT: false}
|
||||
return &AgentConfig{
|
||||
Protocols: ProtocolsConfig{UDP: UDPConfig{Enabled: true}},
|
||||
Metrics: MetricsConfig{
|
||||
CPU: on,
|
||||
Memory: on,
|
||||
Disk: on,
|
||||
Smart: on,
|
||||
Uptime: on,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func DefaultServerConfig() ServerConfig {
|
||||
@@ -95,6 +136,7 @@ func DefaultServerConfig() ServerConfig {
|
||||
RetentionDays: 30, ChartDurationMin: 30,
|
||||
HideOffline: false, Notifications: true,
|
||||
PopupDetailW: 560, PopupDetailH: 600,
|
||||
GaugeType: "compact",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,4 +150,5 @@ type ServerStats struct {
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemUsed int64 `json:"mem_used"`
|
||||
MemTotal int64 `json:"mem_total"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ server {
|
||||
listen 80;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
client_max_body_size 10m;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://server:8080;
|
||||
|
||||
@@ -17,27 +17,37 @@ func StartUDP(addr string, handler func(*models.AgentMetrics)) error {
|
||||
go func() {
|
||||
buf := make([]byte, 65535)
|
||||
for {
|
||||
n, _, err := conn.ReadFrom(buf)
|
||||
n, src, 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)
|
||||
go processUDP(data, src.String(), handler)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func processUDP(data []byte, handler func(*models.AgentMetrics)) {
|
||||
func processUDP(data []byte, src string, handler func(*models.AgentMetrics)) {
|
||||
var m models.AgentMetrics
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
log.Printf("[udp] JSON invalide: %v", err)
|
||||
end := 32
|
||||
if len(data) < end {
|
||||
end = len(data)
|
||||
}
|
||||
log.Printf("[udp] JSON invalide: %v | src=%s | octets: %x", err, src, data[:end])
|
||||
return
|
||||
}
|
||||
if m.Hostname == "" {
|
||||
return
|
||||
}
|
||||
// DEBUG SMART — logguer le payload ASUS complet
|
||||
if m.Smart != nil {
|
||||
log.Printf("[udp] SMART reçu de %s: %d disque(s)", m.Hostname, len(m.Smart))
|
||||
} else {
|
||||
log.Printf("[udp] payload de %s (v%s): smart=nil hdd=%v", m.Hostname, m.Version, m.HDDTotal)
|
||||
}
|
||||
handler(&m)
|
||||
}
|
||||
|
||||