Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e65770407c | |||
| 9e77d961f5 | |||
| 3933301cff | |||
| 9f87c9294d | |||
| 638d347bb0 | |||
| 8f3dbd0532 | |||
| 99bdf79a63 | |||
| a22d1f4cd2 | |||
| d8f395cb53 | |||
| f69c22039b | |||
| 2bda420728 | |||
| f604e22f6e |
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "nanometrics-agent"
|
||||
version = "0.1.2"
|
||||
version = "0.1.3"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
+33
-1
@@ -2,6 +2,13 @@ use nanometrics_agent::{config, metrics, payload, transport};
|
||||
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 get_local_ip() -> String {
|
||||
use std::net::UdpSocket;
|
||||
@@ -49,12 +56,17 @@ 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 {
|
||||
while RUNNING.load(Ordering::Relaxed) {
|
||||
let now = Instant::now();
|
||||
|
||||
while let Ok(transport::mqtt::MqttIncoming::ConfigUpdate(data)) = incoming_rx.try_recv() {
|
||||
@@ -134,4 +146,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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>,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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\""));
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -73,12 +73,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>
|
||||
|
||||
@@ -58,7 +58,7 @@ const Grid = (() => {
|
||||
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)}')">
|
||||
@@ -87,7 +87,7 @@ const Grid = (() => {
|
||||
<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>
|
||||
<span class="g-val">${offline ? '—' : (metrics?.hdd_used && metrics?.hdd_total ? fmt(metrics.hdd_used) + '/' + fmt(metrics.hdd_total) : '—')}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tile-foot">
|
||||
@@ -124,7 +124,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;
|
||||
}
|
||||
|
||||
+21
-2
@@ -30,8 +30,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
|
||||
|
||||
+30
-3
@@ -112,10 +112,33 @@ ok "Répertoire créé"
|
||||
# ── 7. Écrire config.toml ─────────────────────────────────────────────────────
|
||||
echo "[3/5] Écriture de $CONFIG_FILE"
|
||||
|
||||
# Ne pas écraser une config existante (upgrade)
|
||||
OVERWRITE_CONFIG="${OVERWRITE_CONFIG:-}"
|
||||
WRITE_CONFIG=true
|
||||
|
||||
if [ -f "$CONFIG_FILE" ]; then
|
||||
warn "config.toml déjà présent — conservé tel quel"
|
||||
else
|
||||
if [ "${OVERWRITE_CONFIG}" = "true" ]; then
|
||||
warn "OVERWRITE_CONFIG=true — config.toml sera écrasé"
|
||||
WRITE_CONFIG=true
|
||||
elif [ -t 0 ]; then
|
||||
# Mode interactif (bash local, pas curl | bash)
|
||||
echo ""
|
||||
warn "Un config.toml existe déjà :"
|
||||
echo " $CONFIG_FILE"
|
||||
printf " Écraser la configuration existante ? [o/N] : "
|
||||
read -r _ANS
|
||||
if [[ "$_ANS" =~ ^[Oo]$ ]]; then
|
||||
WRITE_CONFIG=true
|
||||
else
|
||||
ok "config.toml conservé"
|
||||
WRITE_CONFIG=false
|
||||
fi
|
||||
else
|
||||
warn "config.toml déjà présent — conservé (relancez avec OVERWRITE_CONFIG=true pour écraser)"
|
||||
WRITE_CONFIG=false
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$WRITE_CONFIG" = "true" ]; then
|
||||
cat > "$CONFIG_FILE" << TOML
|
||||
[server]
|
||||
ip = "$SERVER_IP"
|
||||
@@ -165,6 +188,10 @@ TOML
|
||||
ok "config.toml créé"
|
||||
fi
|
||||
|
||||
# S'assurer que le fichier est toujours lisible (cas d'un config existant en 640)
|
||||
chmod 644 "$CONFIG_FILE" 2>/dev/null || true
|
||||
chmod 755 "$CONFIG_DIR"
|
||||
|
||||
# ── 8. Installer le fichier service ──────────────────────────────────────────
|
||||
echo "[4/5] Installation du service systemd"
|
||||
curl -fsSL -o "$SERVICE_FILE" "$SERVICE_URL"
|
||||
|
||||
@@ -11,7 +11,7 @@ RestartSec=5
|
||||
|
||||
DynamicUser=yes
|
||||
ConfigurationDirectory=nanometrics
|
||||
ConfigurationDirectoryMode=0750
|
||||
ConfigurationDirectoryMode=0755
|
||||
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
|
||||
+3
-1
@@ -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")
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
+4
-5
@@ -5,10 +5,9 @@ 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"]
|
||||
|
||||
@@ -128,6 +128,72 @@ 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 smartPassed, smartTemp, smartRealloc, smartHours, smartWear *int64
|
||||
|
||||
// Chaque sous-requête prend la dernière valeur NON NULL de sa colonne.
|
||||
// Nécessaire car les paquets rapides (2s) ne contiennent pas les métriques
|
||||
// lentes (disque, smart) qui sont envoyées toutes les 60s.
|
||||
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_passed FROM metrics WHERE agent_id=? AND smart_passed IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
||||
(SELECT smart_temp FROM metrics WHERE agent_id=? AND smart_passed IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
||||
(SELECT smart_realloc FROM metrics WHERE agent_id=? AND smart_passed IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
||||
(SELECT smart_hours FROM metrics WHERE agent_id=? AND smart_passed IS NOT NULL ORDER BY ts DESC LIMIT 1),
|
||||
(SELECT smart_wear FROM metrics WHERE agent_id=? AND smart_passed IS NOT NULL ORDER BY ts DESC LIMIT 1)`,
|
||||
agentID, agentID, agentID, agentID,
|
||||
agentID, agentID, agentID,
|
||||
agentID, agentID, agentID, agentID,
|
||||
agentID, agentID, agentID, agentID, agentID).
|
||||
Scan(&cpu, &memUsed, &memFree, &memTotal,
|
||||
&hddUsed, &hddFree, &hddTotal,
|
||||
&uptime, &netRX, &netTX, &temperature,
|
||||
&smartPassed, &smartTemp, &smartRealloc, &smartHours, &smartWear)
|
||||
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 smartPassed != nil {
|
||||
m.Smart = &models.SmartMetrics{
|
||||
Passed: *smartPassed == 1,
|
||||
Temperature: smartTemp,
|
||||
ReallocatedSectors: smartRealloc,
|
||||
PowerOnHours: smartHours,
|
||||
WearLevel: smartWear,
|
||||
}
|
||||
}
|
||||
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
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
version: '3.8'
|
||||
services:
|
||||
server:
|
||||
build: .
|
||||
build:
|
||||
context: .
|
||||
pull: false
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
UDP_ADDR: "0.0.0.0:9999"
|
||||
@@ -16,6 +17,7 @@ services:
|
||||
|
||||
dashboard:
|
||||
image: nginx:alpine
|
||||
pull_policy: if_not_present
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
|
||||
@@ -15,6 +15,9 @@ 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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
BIN
Binary file not shown.
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user