diff --git a/Makefile b/Makefile index 09862b1..1442fcc 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: proto server agent web dev up-server up-agent +.PHONY: proto server agent web dev up-server up-agent up-server-local up-agent-local down-server-local down-agent-local PROTO_DIR := proto/agent/v1 PROTO_OUT := server/internal/proto/agentv1 @@ -36,9 +36,27 @@ down-server: down-agent: docker compose -f docker-compose.agent.yml down +up-local: + docker compose -f docker-compose.local.yml up --build -d + +down-local: + docker compose -f docker-compose.local.yml down + +up-server-local: + docker compose -f docker-compose.server.local.yml up --build -d + +up-agent-local: + docker compose -f docker-compose.agent.local.yml up --build -d + +down-server-local: + docker compose -f docker-compose.server.local.yml down + +down-agent-local: + docker compose -f docker-compose.agent.local.yml down + # ── Dev (local, no Docker) ─────────────────────────────────────────────────── dev-server: - cd server && go run ./cmd/server + cd server && DB_PATH=../.data/nexarr.db JWT_SECRET=dev-secret ADMIN_USER=admin ADMIN_PASSWORD=admin go run ./cmd/server dev-web: cd web && npm run dev diff --git a/agent/Cargo.lock b/agent/Cargo.lock index 0ab9f4e..013f7e6 100644 --- a/agent/Cargo.lock +++ b/agent/Cargo.lock @@ -235,6 +235,7 @@ dependencies = [ "prost", "serde", "serde_json", + "sysinfo", "tokio", "tokio-stream", "tonic", @@ -606,7 +607,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -866,6 +867,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1449,6 +1459,19 @@ dependencies = [ "syn", ] +[[package]] +name = "sysinfo" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c33cd241af0f2e9e3b5c32163b873b29956890b5342e6745b917ce9d490f4af" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "windows", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -1945,19 +1968,52 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets", +] + [[package]] name = "windows-core" version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link", - "windows-result", + "windows-result 0.4.1", "windows-strings", ] +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -1969,6 +2025,17 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -1986,6 +2053,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-result" version = "0.4.1" diff --git a/agent/Cargo.toml b/agent/Cargo.toml index 4489f72..18ad8e1 100644 --- a/agent/Cargo.toml +++ b/agent/Cargo.toml @@ -20,6 +20,7 @@ tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } anyhow = "1" tokio-stream = "0.1" futures-util = "0.3" +sysinfo = { version = "0.32", default-features = false, features = ["system", "network", "disk", "user"] } [dev-dependencies] # `bytes::Bytes` is used in MockBackend::logs() to construct LogOutput variants diff --git a/agent/src/docker.rs b/agent/src/docker.rs index f4eddf0..4069779 100644 --- a/agent/src/docker.rs +++ b/agent/src/docker.rs @@ -241,6 +241,26 @@ impl ContainerBackend for DockerClient { async fn list_networks(&self) -> Result> { const SYSTEM_NETWORKS: &[&str] = &["bridge", "host", "none"]; + // Cross-reference containers to find which networks are actually in use. + // NOTE: GET /networks never populates the `containers` field — only + // individual inspect calls do — so we must build the used-set ourselves. + let containers = self + .inner + .list_containers(Some(ListContainersOptions:: { + all: true, + ..Default::default() + })) + .await + .unwrap_or_default(); + + let used_network_ids: std::collections::HashSet = containers + .iter() + .filter_map(|c| c.network_settings.as_ref()) + .filter_map(|ns| ns.networks.as_ref()) + .flat_map(|nets| nets.values()) + .filter_map(|ep| ep.network_id.clone()) + .collect(); + let networks = self .inner .list_networks(None::>) @@ -250,17 +270,11 @@ impl ContainerBackend for DockerClient { .into_iter() .map(|n| { let name = n.name.clone().unwrap_or_default(); + let id = n.id.clone().unwrap_or_default(); let is_system = SYSTEM_NETWORKS.contains(&name.as_str()); - // A network is orphan if it is not a system network and has no - // containers connected to it (the `containers` map is absent or empty). - let has_containers = n - .containers - .as_ref() - .map(|m| !m.is_empty()) - .unwrap_or(false); - let is_orphan = !is_system && !has_containers; + let is_orphan = !is_system && !used_network_ids.contains(&id); NetworkInfo { - id: n.id.unwrap_or_default(), + id, name, driver: n.driver.unwrap_or_default(), scope: n.scope.unwrap_or_default(), @@ -559,6 +573,14 @@ pub mod tests { assert_eq!(networks[0].name, "bridge"); } + #[tokio::test] + async fn mock_list_networks_bridge_is_not_orphan() { + let backend = MockBackend::new(); + let networks = backend.list_networks().await.unwrap(); + // "bridge" is a system network and must never be flagged as orphan. + assert!(!networks[0].is_orphan, "system network 'bridge' must not be orphan"); + } + #[tokio::test] async fn mock_list_networks_records_call() { let backend = MockBackend::new(); diff --git a/agent/src/main.rs b/agent/src/main.rs index 9cc946a..1540117 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -1,4 +1,5 @@ mod docker; +mod stats; pub mod proto { tonic::include_proto!("containarr.agent.v1"); @@ -11,7 +12,7 @@ use bollard::container::{ }; use bollard::network::ConnectNetworkOptions; use bollard::models::EndpointSettings; -use bollard::image::CreateImageOptions; +use bollard::image::{CreateImageOptions, RemoveImageOptions}; use docker::{ContainerBackend, DockerClient}; use futures_util::StreamExt as _; use proto::{ @@ -21,6 +22,8 @@ use proto::{ FileResult, ImageInfo, VolumeInfo, NetworkInfo, UpdateCheckResult, }; +use stats::PrevNetStats; +use sysinfo::System; use serde::Serialize; use std::{collections::HashMap, env, time::Duration}; use tokio::{sync::mpsc, task::JoinHandle, time}; @@ -80,6 +83,12 @@ async fn run(url: &str, token: &str, hostname: &str, docker: DockerClient) -> Re let mut snapshot_ticker = time::interval(SNAPSHOT_INTERVAL); let mut log_tasks: HashMap> = HashMap::new(); + // System is kept alive between ticks so CPU usage reflects a real delta + let mut sys = System::new_all(); + // Initial refresh to establish baseline for the first CPU delta + sys.refresh_all(); + let mut prev_net = PrevNetStats::new(); + loop { tokio::select! { _ = snapshot_ticker.tick() => { @@ -123,6 +132,16 @@ async fn run(url: &str, token: &str, hostname: &str, docker: DockerClient) -> Re if tx.send(msg).await.is_err() { break; } + + // Collect and send system stats snapshot + let stats_snapshot = stats::collect_stats(&mut sys, &mut prev_net); + let stats_msg = AgentMessage { + payload: Some(agent_message::Payload::StatsSnapshot(stats_snapshot)), + }; + if let Err(e) = tx.send(stats_msg).await { + warn!("failed to send StatsSnapshot: {:#}", e); + break; + } } result = inbound.message() => { @@ -640,6 +659,9 @@ async fn standalone_recreate( .unwrap_or_default() }; + // Récupérer l'ID de l'ancienne image avant le pull + let old_image_id: Option = inspect.image.clone(); + // 1. Pull the new image pull_image_and_detect_update(docker, &image_name).await?; @@ -708,6 +730,22 @@ async fn standalone_recreate( } info!("container {container_name} recreated with new image {image_name}"); + + // Supprimer l'ancienne image si elle est devenue orpheline (best-effort) + if let Some(old_id) = old_image_id { + match docker + .remove_image( + &old_id, + Some(RemoveImageOptions { force: false, noprune: false }), + None, + ) + .await + { + Ok(_) => info!("cleaned up old image {old_id} after update"), + Err(e) => info!("old image {old_id} not removed (still in use or already gone): {e}"), + } + } + Ok(()) } diff --git a/agent/src/stats.rs b/agent/src/stats.rs new file mode 100644 index 0000000..0b09065 --- /dev/null +++ b/agent/src/stats.rs @@ -0,0 +1,242 @@ +use std::time::{SystemTime, UNIX_EPOCH}; +use sysinfo::{ + CpuRefreshKind, Disks, MemoryRefreshKind, Networks, ProcessRefreshKind, + RefreshKind, System, +}; + +use crate::proto::{DiskStats, NetworkInterfaceStats, ProcessInfo, StatsSnapshot}; + +/// Stores previous network counters to compute per-snapshot rates. +pub struct PrevNetStats { + /// Map of interface name → (bytes_recv, bytes_sent) + pub counters: std::collections::HashMap, + /// Unix timestamp (secs) of last snapshot + pub timestamp: u64, +} + +impl PrevNetStats { + pub fn new() -> Self { + Self { + counters: std::collections::HashMap::new(), + timestamp: unix_secs(), + } + } +} + +fn unix_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +/// Collect a full system stats snapshot. +/// +/// `sys` must be kept alive between calls so that CPU usage reflects a real +/// delta (sysinfo computes CPU% as difference between two refreshes). +pub fn collect_stats(sys: &mut System, prev_net: &mut PrevNetStats) -> StatsSnapshot { + // Refresh CPU and memory + sys.refresh_specifics( + RefreshKind::new() + .with_cpu(CpuRefreshKind::new().with_cpu_usage()) + .with_memory(MemoryRefreshKind::new().with_ram()) + .with_processes(ProcessRefreshKind::new().with_cpu().with_memory()), + ); + + // ── CPU ─────────────────────────────────────────────────────────────────── + let cpu_pct = sys.global_cpu_usage() as f64; + let cpu_per_core: Vec = sys.cpus().iter().map(|c| c.cpu_usage() as f64).collect(); + + // ── Memory ──────────────────────────────────────────────────────────────── + let mem_total = sys.total_memory(); + let mem_used = sys.used_memory(); + let mem_available = sys.available_memory(); + + // ── Processes — top 20 by CPU usage ────────────────────────────────────── + let mut proc_list: Vec = sys + .processes() + .values() + .map(|p| { + let cmd = p + .cmd() + .iter() + .map(|s| s.to_string_lossy()) + .collect::>() + .join(" "); + ProcessInfo { + pid: p.pid().as_u32(), + name: p.name().to_string_lossy().to_string(), + cmd, + cpu_pct: p.cpu_usage() as f64, + mem_rss: p.memory(), + } + }) + .collect(); + + proc_list.sort_by(|a, b| b.cpu_pct.partial_cmp(&a.cpu_pct).unwrap_or(std::cmp::Ordering::Equal)); + proc_list.truncate(20); + + // ── Networks ───────────────────────────────────────────────────────────── + let mut networks = Networks::new_with_refreshed_list(); + networks.refresh(); + + let now_secs = unix_secs(); + let elapsed = now_secs.saturating_sub(prev_net.timestamp).max(1); + + let net_interfaces: Vec = networks + .iter() + .map(|(name, data)| { + let recv = data.total_received(); + let sent = data.total_transmitted(); + + let (prev_recv, prev_sent) = prev_net + .counters + .get(name.as_str()) + .copied() + .unwrap_or((recv, sent)); + + let recv_rate = recv.saturating_sub(prev_recv) / elapsed; + let sent_rate = sent.saturating_sub(prev_sent) / elapsed; + + NetworkInterfaceStats { + name: name.to_string(), + bytes_recv: recv, + bytes_sent: sent, + bytes_recv_rate: recv_rate, + bytes_sent_rate: sent_rate, + } + }) + .collect(); + + // Update previous counters + prev_net.counters.clear(); + for (name, data) in networks.iter() { + prev_net + .counters + .insert(name.to_string(), (data.total_received(), data.total_transmitted())); + } + prev_net.timestamp = now_secs; + + // ── Disks ───────────────────────────────────────────────────────────────── + let mut disk_list = Disks::new_with_refreshed_list(); + disk_list.refresh(); + + let disks: Vec = disk_list + .iter() + .filter(|d| !d.is_removable()) + .map(|d| { + let total = d.total_space(); + let free = d.available_space(); + let used = total.saturating_sub(free); + DiskStats { + path: d.mount_point().to_string_lossy().to_string(), + total, + used, + free, + } + }) + .collect(); + + StatsSnapshot { + cpu_pct, + cpu_per_core, + mem_total, + mem_used, + mem_available, + net_interfaces, + processes: proc_list, + disks, + timestamp: now_secs as i64, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prev_net_stats_initializes_empty() { + let prev = PrevNetStats::new(); + assert!(prev.counters.is_empty()); + assert!(prev.timestamp > 0); + } + + #[test] + fn collect_stats_returns_plausible_values() { + let mut sys = System::new_all(); + // First refresh to establish baseline for CPU delta + sys.refresh_all(); + let mut prev = PrevNetStats::new(); + + let snapshot = collect_stats(&mut sys, &mut prev); + + // CPU should be a percentage 0-100 (per core can spike > 100 on some OSes, skip that) + assert!(snapshot.cpu_pct >= 0.0 && snapshot.cpu_pct <= 100.0 * sys.cpus().len() as f64 + 1.0); + + // Memory values must be non-zero and consistent + assert!(snapshot.mem_total > 0); + assert!(snapshot.mem_used <= snapshot.mem_total); + + // Timestamp should be recent (after year 2020) + assert!(snapshot.timestamp > 1_577_836_800); + + // Should have at least one CPU core reported + assert!(!snapshot.cpu_per_core.is_empty()); + } + + #[test] + fn collect_stats_updates_prev_net_counters() { + let mut sys = System::new_all(); + sys.refresh_all(); + let mut prev = PrevNetStats::new(); + + collect_stats(&mut sys, &mut prev); + + // After first call, prev counters should be populated + // (may be empty if no network interfaces exist in test env, but timestamp must update) + assert!(prev.timestamp > 0); + } + + #[test] + fn rate_calculation_does_not_overflow() { + // Simulate a case where prev > current (counter reset), saturating_sub handles it + let prev_recv: u64 = 1_000_000; + let current_recv: u64 = 500; // counter wrapped/reset + let elapsed: u64 = 10; + let rate = current_recv.saturating_sub(prev_recv) / elapsed; + assert_eq!(rate, 0); // saturating_sub returns 0 on underflow + } + + #[test] + fn collect_stats_disks_are_non_removable() { + let mut sys = System::new_all(); + sys.refresh_all(); + let mut prev = PrevNetStats::new(); + + let snapshot = collect_stats(&mut sys, &mut prev); + + // All disks in snapshot must have a non-empty mount point + for disk in &snapshot.disks { + assert!(!disk.path.is_empty()); + assert!(disk.total >= disk.used); + } + } + + #[test] + fn collect_stats_processes_top20() { + let mut sys = System::new_all(); + sys.refresh_all(); + let mut prev = PrevNetStats::new(); + + let snapshot = collect_stats(&mut sys, &mut prev); + + // At most 20 processes + assert!(snapshot.processes.len() <= 20); + + // Processes are sorted by cpu_pct descending + let cpus: Vec = snapshot.processes.iter().map(|p| p.cpu_pct).collect(); + for i in 1..cpus.len() { + assert!(cpus[i - 1] >= cpus[i], "processes not sorted by cpu_pct desc"); + } + } +} diff --git a/docker-compose.agent.local.yml b/docker-compose.agent.local.yml new file mode 100644 index 0000000..d9d5abb --- /dev/null +++ b/docker-compose.agent.local.yml @@ -0,0 +1,14 @@ +name: containarr-local + +services: + agent: + build: + context: . + dockerfile: agent/Dockerfile + restart: unless-stopped + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + CONTAINARR_SERVER_URL: "http://server:9090" + CONTAINARR_AGENT_TOKEN: "3fd97ce6-1c03-4609-b2a1-5949d69da4df" + RUST_LOG: "info" diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 0000000..f7bafcb --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,45 @@ +name: nexarr-local + +services: + server: + build: + context: . + dockerfile: server/Dockerfile + restart: unless-stopped + ports: + - "8080:8080" + - "9090:9090" + volumes: + - nexarr-data:/data + environment: + DB_PATH: /data/nexarr.db + HTTP_ADDR: ":8080" + GRPC_ADDR: ":9090" + JWT_SECRET: "dev-secret" + ADMIN_USER: "admin" + ADMIN_PASSWORD: "admin" + BOOTSTRAP_TOKENS: "local-agent:dev-agent-token" + networks: + - nexarr + + agent: + build: + context: . + dockerfile: agent/Dockerfile + restart: unless-stopped + depends_on: + - server + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + environment: + CONTAINARR_SERVER_URL: "http://server:9090" + CONTAINARR_AGENT_TOKEN: "dev-agent-token" + RUST_LOG: "info" + networks: + - nexarr + +networks: + nexarr: + +volumes: + nexarr-data: diff --git a/docker-compose.server.local.yml b/docker-compose.server.local.yml new file mode 100644 index 0000000..7744d47 --- /dev/null +++ b/docker-compose.server.local.yml @@ -0,0 +1,23 @@ +name: containarr-local + +services: + server: + build: + context: . + dockerfile: server/Dockerfile + restart: unless-stopped + ports: + - "8080:8080" # HTTP + WebSocket (PWA) + - "9090:9090" # gRPC (agents) + volumes: + - containarr-data:/data + environment: + DB_PATH: /data/containarr.db + HTTP_ADDR: ":8080" + GRPC_ADDR: ":9090" + JWT_SECRET: "change-me-to-a-random-secret" + ADMIN_USER: "admin" + ADMIN_PASSWORD: "change-me" + +volumes: + containarr-data: diff --git a/proto/agent/v1/agent.proto b/proto/agent/v1/agent.proto index ccc4482..1b803a7 100644 --- a/proto/agent/v1/agent.proto +++ b/proto/agent/v1/agent.proto @@ -95,14 +95,50 @@ message UpdateCheckResult { string error = 6; } +message ProcessInfo { + uint32 pid = 1; + string name = 2; + string cmd = 3; + double cpu_pct = 4; + uint64 mem_rss = 5; +} + +message NetworkInterfaceStats { + string name = 1; + uint64 bytes_recv = 2; + uint64 bytes_sent = 3; + uint64 bytes_recv_rate = 4; // bytes/s since last snapshot + uint64 bytes_sent_rate = 5; +} + +message DiskStats { + string path = 1; + uint64 total = 2; + uint64 used = 3; + uint64 free = 4; +} + +message StatsSnapshot { + double cpu_pct = 1; + repeated double cpu_per_core = 2; + uint64 mem_total = 3; + uint64 mem_used = 4; + uint64 mem_available = 5; + repeated NetworkInterfaceStats net_interfaces = 6; + repeated ProcessInfo processes = 7; + repeated DiskStats disks = 8; + int64 timestamp = 9; +} + message AgentMessage { oneof payload { - AgentHandshake handshake = 1; - ContainerSnapshot snapshot = 2; - CommandResult result = 3; - LogChunk log_chunk = 4; - FileResult file_result = 5; + AgentHandshake handshake = 1; + ContainerSnapshot snapshot = 2; + CommandResult result = 3; + LogChunk log_chunk = 4; + FileResult file_result = 5; UpdateCheckResult update_check_result = 6; + StatsSnapshot stats_snapshot = 7; } } diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 734d947..b8a01b8 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -124,7 +124,7 @@ func (h *Handler) ListContainers(w http.ResponseWriter, r *http.Request) { IPAddress string `json:"ip_address"` Container *agentv1.ContainerInfo `json:"container"` } - var out []containerDTO + out := make([]containerDTO, 0) for _, agent := range h.registry.List() { for _, c := range agent.Containers { out = append(out, containerDTO{ @@ -193,7 +193,7 @@ func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) { CreatedAt int64 `json:"created_at"` IsOrphan bool `json:"is_orphan"` } - var out []imageDTO + out := make([]imageDTO, 0) for _, agent := range h.registry.List() { for _, img := range agent.Images { out = append(out, imageDTO{ @@ -247,7 +247,7 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) { Mountpoint string `json:"mountpoint"` IsOrphan bool `json:"is_orphan"` } - var out []volumeDTO + out := make([]volumeDTO, 0) for _, agent := range h.registry.List() { for _, vol := range agent.Volumes { out = append(out, volumeDTO{ @@ -301,7 +301,7 @@ func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) { Scope string `json:"scope"` IsOrphan bool `json:"is_orphan"` } - var out []networkDTO + out := make([]networkDTO, 0) for _, agent := range h.registry.List() { for _, net := range agent.Networks { out = append(out, networkDTO{ @@ -753,6 +753,129 @@ func (h *Handler) UpdateNow(w http.ResponseWriter, r *http.Request) { jsonOK(w, map[string]string{"command_id": cmdID}) } +// ── Stats ───────────────────────────────────────────────────────────────────── + +type processDTO struct { + Pid uint32 `json:"pid"` + Name string `json:"name"` + Cmd string `json:"cmd"` + CpuPct float64 `json:"cpu_pct"` + MemRss uint64 `json:"mem_rss"` +} + +type netIfaceDTO struct { + Name string `json:"name"` + BytesRecv uint64 `json:"bytes_recv"` + BytesSent uint64 `json:"bytes_sent"` + BytesRecvRate uint64 `json:"bytes_recv_rate"` + BytesSentRate uint64 `json:"bytes_sent_rate"` +} + +type diskDTO struct { + Path string `json:"path"` + Total uint64 `json:"total"` + Used uint64 `json:"used"` + Free uint64 `json:"free"` +} + +type statsSnapshotDTO struct { + CpuPct float64 `json:"cpu_pct"` + CpuPerCore []float64 `json:"cpu_per_core"` + MemTotal uint64 `json:"mem_total"` + MemUsed uint64 `json:"mem_used"` + MemAvailable uint64 `json:"mem_available"` + NetInterfaces []netIfaceDTO `json:"net_interfaces"` + Processes []processDTO `json:"processes"` + Disks []diskDTO `json:"disks"` + Timestamp int64 `json:"timestamp"` +} + +func protoStatsToDTO(s *agentv1.StatsSnapshot) *statsSnapshotDTO { + if s == nil { + return nil + } + dto := &statsSnapshotDTO{ + CpuPct: s.CpuPct, + CpuPerCore: s.CpuPerCore, + MemTotal: s.MemTotal, + MemUsed: s.MemUsed, + MemAvailable: s.MemAvailable, + Timestamp: s.Timestamp, + } + if dto.CpuPerCore == nil { + dto.CpuPerCore = []float64{} + } + for _, iface := range s.NetInterfaces { + dto.NetInterfaces = append(dto.NetInterfaces, netIfaceDTO{ + Name: iface.Name, + BytesRecv: iface.BytesRecv, + BytesSent: iface.BytesSent, + BytesRecvRate: iface.BytesRecvRate, + BytesSentRate: iface.BytesSentRate, + }) + } + if dto.NetInterfaces == nil { + dto.NetInterfaces = []netIfaceDTO{} + } + for _, p := range s.Processes { + dto.Processes = append(dto.Processes, processDTO{ + Pid: p.Pid, + Name: p.Name, + Cmd: p.Cmd, + CpuPct: p.CpuPct, + MemRss: p.MemRss, + }) + } + if dto.Processes == nil { + dto.Processes = []processDTO{} + } + for _, d := range s.Disks { + dto.Disks = append(dto.Disks, diskDTO{ + Path: d.Path, + Total: d.Total, + Used: d.Used, + Free: d.Free, + }) + } + if dto.Disks == nil { + dto.Disks = []diskDTO{} + } + return dto +} + +// ListStats handles GET /api/v1/stats — returns system stats for all connected agents. +func (h *Handler) ListStats(w http.ResponseWriter, r *http.Request) { + type statsDTO struct { + AgentID string `json:"agent_id"` + Hostname string `json:"hostname"` + Alias string `json:"alias"` + IPAddress string `json:"ip_address"` + Stats *statsSnapshotDTO `json:"stats"` + } + out := make([]statsDTO, 0) + for _, agent := range h.registry.List() { + out = append(out, statsDTO{ + AgentID: agent.ID, + Hostname: agent.Hostname, + Alias: agent.Alias, + IPAddress: agent.IPAddress, + Stats: protoStatsToDTO(agent.Stats), + }) + } + jsonOK(w, out) +} + +// GetAgentStats handles GET /api/v1/agents/{agentID}/stats — returns stats for a single agent. +func (h *Handler) GetAgentStats(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + state, ok := h.registry.Get(agentID) + if !ok { + http.Error(w, "agent not connected", http.StatusNotFound) + return + } + jsonOK(w, protoStatsToDTO(state.Stats)) +} + func jsonOK(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(v) diff --git a/server/internal/api/router.go b/server/internal/api/router.go index d3ddeaa..1992072 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -63,6 +63,8 @@ func NewRouter(h *Handler) http.Handler { r.Get("/agents/{agentID}/containers/{containerID}/auto-update", h.GetAutoUpdatePolicy) r.Put("/agents/{agentID}/containers/{containerID}/auto-update", h.PutAutoUpdatePolicy) r.Post("/agents/{agentID}/containers/{containerID}/update-now", h.UpdateNow) + r.Get("/stats", h.ListStats) + r.Get("/agents/{agentID}/stats", h.GetAgentStats) }) }) diff --git a/server/internal/grpc/gateway.go b/server/internal/grpc/gateway.go index 94df5c1..60251c4 100644 --- a/server/internal/grpc/gateway.go +++ b/server/internal/grpc/gateway.go @@ -156,6 +156,14 @@ func (g *Gateway) Tunnel(stream agentv1.AgentGateway_TunnelServer) error { case *agentv1.AgentMessage_FileResult: g.registry.ResolvePending(agentID, p.FileResult.CommandId, p.FileResult) + case *agentv1.AgentMessage_StatsSnapshot: + g.registry.UpdateStats(agentID, p.StatsSnapshot) + g.broker.Publish(broker.Event{ + Type: "stats.updated", + AgentID: agentID, + Payload: p.StatsSnapshot, + }) + case *agentv1.AgentMessage_UpdateCheckResult: res := p.UpdateCheckResult if res.Error != "" { diff --git a/server/internal/grpc/registry.go b/server/internal/grpc/registry.go index 4570c0f..3fb0ba7 100644 --- a/server/internal/grpc/registry.go +++ b/server/internal/grpc/registry.go @@ -22,6 +22,7 @@ type AgentState struct { Volumes []*agentv1.VolumeInfo Networks []*agentv1.NetworkInfo + Stats *agentv1.StatsSnapshot cmdCh chan *agentv1.ServerMessage pendingFiles map[string]chan *agentv1.FileResult pendingUpdates map[string]string // commandID → containerID @@ -102,6 +103,15 @@ func (r *Registry) UpdateResources(id string, containers []*agentv1.ContainerInf } } +func (r *Registry) UpdateStats(id string, stats *agentv1.StatsSnapshot) { + r.mu.Lock() + defer r.mu.Unlock() + if s, ok := r.agents[id]; ok { + s.Stats = stats + s.LastSeenAt = time.Now() + } +} + // UpdateAlias refreshes the alias for a live agent (called after an admin update). func (r *Registry) UpdateAlias(id, alias string) { r.mu.Lock() diff --git a/web/src/lib/Sidebar.svelte b/web/src/lib/Sidebar.svelte new file mode 100644 index 0000000..de4738f --- /dev/null +++ b/web/src/lib/Sidebar.svelte @@ -0,0 +1,173 @@ + + + +
+ + +
+ Nexarr + Nexarr +
+
+ + +{#if mobileOpen} + +
(mobileOpen = false)} + >
+{/if} + + + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index e2892fd..a5b446f 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -314,6 +314,62 @@ export async function updateNow(agentId: string, containerId: string): Promise<{ return r.json(); } +export interface ProcessInfo { + pid: number; + name: string; + cmd: string; + cpu_pct: number; + mem_rss: number; +} + +export interface NetworkInterfaceStats { + name: string; + bytes_recv: number; + bytes_sent: number; + bytes_recv_rate: number; + bytes_sent_rate: number; +} + +export interface DiskStats { + path: string; + total: number; + used: number; + free: number; +} + +export interface AgentStatsSnapshot { + cpu_pct: number; + cpu_per_core: number[]; + mem_total: number; + mem_used: number; + mem_available: number; + net_interfaces: NetworkInterfaceStats[]; + processes: ProcessInfo[]; + disks: DiskStats[]; + timestamp: number; +} + +export interface AgentStats { + agent_id: string; + hostname: string; + alias: string; + ip_address: string; + stats: AgentStatsSnapshot | null; +} + +export async function fetchAllStats(): Promise { + const ac = new AbortController(); + const t = setTimeout(() => ac.abort(), 8000); + try { + const r = await apiFetch(`${BASE}/stats`, { signal: ac.signal }); + if (!r.ok) throw new Error(`stats: ${r.status}`); + const data = await r.json(); + return Array.isArray(data) ? data : []; + } finally { + clearTimeout(t); + } +} + export function connectEvents( onEvent: (evt: { type: string; agent_id?: string; payload: unknown }) => void ): () => void { diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 78f40a1..43dfef5 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -4,16 +4,29 @@ import { page } from "$app/stores"; import { goto } from "$app/navigation"; import { isLoggedIn } from "$lib/auth"; + import Sidebar from "$lib/Sidebar.svelte"; import type { Snippet } from "svelte"; let { children }: { children: Snippet } = $props(); + const publicRoutes = ["/login"]; + onMount(() => { - const publicRoutes = ["/login"]; if (!publicRoutes.includes($page.url.pathname) && !isLoggedIn()) { goto("/login"); } }); + + const isPublic = $derived(publicRoutes.includes($page.url.pathname)); -{@render children()} +{#if isPublic} + {@render children()} +{:else} +
+ +
+ {@render children()} +
+
+{/if} diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 34b64ea..e8f5946 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -5,7 +5,6 @@ prompt(): Promise; readonly userChoice: Promise<{ outcome: "accepted" | "dismissed" }>; } - import { goto } from "$app/navigation"; import { fetchContainers, fetchImages, @@ -26,7 +25,6 @@ type NetworkEntry, type AutoUpdatePolicy, } from "$lib/api"; - import { clearToken } from "$lib/auth"; import LogModal from "$lib/LogModal.svelte"; // ── Logs modal ──────────────────────────────────────────────────────────── @@ -48,11 +46,6 @@ }; } - function logout() { - clearToken(); - goto("/login"); - } - // ── Tab state ───────────────────────────────────────────────────────────── type Tab = "containers" | "images" | "volumes" | "networks"; let activeTab = $state("containers"); @@ -731,7 +724,7 @@ - Containarr + Nexarr {#if logTarget} @@ -758,38 +751,21 @@
diff --git a/web/src/routes/compose/+page.svelte b/web/src/routes/compose/+page.svelte index 753df1c..6069fe8 100644 --- a/web/src/routes/compose/+page.svelte +++ b/web/src/routes/compose/+page.svelte @@ -1,6 +1,5 @@ - Compose — Containarr + Compose — Nexarr @@ -279,38 +483,6 @@
- -
-
- Containarr - Containarr -
- -
- - - - - - - - - - - - - - -
-
-
@@ -532,6 +704,341 @@ />
+ +
+ Outils + + +
+ + {#if openTool === 'path'} +
+ + +
+ + {toolPath} + {#if toolBrowseLoading} +
+ {/if} +
+ + + + + + + + +
+ {#each toolDirEntries.filter(e => toolPathSearch.trim() === '' || e.name.toLowerCase().includes(toolPathSearch.toLowerCase())) as entry} + {#if entry.is_dir} + +
+ + + +
+ {:else} + + + {/if} + {/each} + + {#if toolDirEntries.length === 0 && !toolBrowseLoading} +

Dossier vide

+ {/if} +
+ +
+ {/if} +
+ +
+ + +
+ + {#if openTool === 'timezone'} +
+ +
+ {#each filteredTz as tz} + + {/each} +
+
+ {/if} +
+ +
+ + +
+ + {#if openTool === 'network'} +
+ {#if agentNetworks.length === 0} +

Aucun network

+ {/if} + {#each agentNetworks.filter(n => n.name !== 'bridge' && n.name !== 'host' && n.name !== 'none') as net} + + {/each} +
+ {/if} +
+ +
+ + +
+ + {#if openTool === 'port'} +
+ +
+ + {#if portInput} + {#if portConflict} +

❌ Utilisé par {portConflict.container}

+ {:else} +

✓ Disponible

+ {/if} + {/if} +
+ + +
+ {#if usedPorts.length === 0} +

Aucun port utilisé

+ {/if} + {#each usedPorts.filter(p => portSearch.trim() === '' || String(p.port).includes(portSearch)) as p} + + {/each} +
+
+ {/if} +
+ +
+ + +
+ + {#if openTool === 'name'} +
+ +
+ + {#if nameInput.trim()} + {#if nameConflict} +

❌ Déjà utilisé

+ {:else} +

✓ Disponible

+ {/if} + {/if} +
+ + +
+ {#if usedNames.length === 0} +

Aucun container

+ {/if} + {#each usedNames.filter(n => nameSearch.trim() === '' || n.toLowerCase().includes(nameSearch.toLowerCase())) as name} + + {/each} +
+
+ {/if} +
+
+ diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte index dee15ea..b740046 100644 --- a/web/src/routes/login/+page.svelte +++ b/web/src/routes/login/+page.svelte @@ -23,7 +23,7 @@ - Connexion — Containarr + Connexion — Nexarr
@@ -31,10 +31,10 @@
- Containarr + Nexarr
-

Containarr

-

Gestionnaire de containers Docker

+

Nexarr

+

Infrastructure management dashboard

diff --git a/web/src/routes/proxy/+page.svelte b/web/src/routes/proxy/+page.svelte new file mode 100644 index 0000000..a49c654 --- /dev/null +++ b/web/src/routes/proxy/+page.svelte @@ -0,0 +1,88 @@ + + Reverse Proxy — Nexarr + + +
+ + +
+
+ + + + Reverse Proxy +
+
+ +
+ + +
+ + +
+
+ + + +
+
+
Nginx
+
Manage virtual hosts, upstreams and SSL termination
+
+
+ + +
+
+ + + +
+
+
Traefik
+
Dynamic routing rules and middlewares via labels
+
+
+ + +
+
+ + + +
+
+
Caddy
+
Automatic HTTPS with Caddyfile management
+
+
+ +
+ + +
+
+ + + +
+
+

Coming soon

+

+ Manage your reverse proxy configurations per agent — create, edit and deploy Nginx, Traefik or Caddy configs directly from the UI. +

+
+
+ + In development +
+
+ +
+
diff --git a/web/src/routes/stats/+page.svelte b/web/src/routes/stats/+page.svelte new file mode 100644 index 0000000..2640fb1 --- /dev/null +++ b/web/src/routes/stats/+page.svelte @@ -0,0 +1,547 @@ + + + + Statistics — Nexarr + + +
+ + +
+
+ + + + Statistics + {#if statsList !== null} + {statsList.length} host{statsList.length !== 1 ? "s" : ""} + {/if} +
+ +
+
+
+ +
+ + + {#if statsList === null} +
+
+ Loading… +
+ + + {:else if loadError} +
+ +

{loadError}

+
+ + + {:else if statsList.length === 0} +
+ + + + No agents connected +
+ + + {:else} + {#each statsList.slice().sort((a, b) => (a.alias || a.hostname).localeCompare(b.alias || b.hostname)) as agent (agent.agent_id)} +
+ + + + + +
+ {#if agent.stats} + {@const s = agent.stats} + {@const mp = memPct(s)} + +
+ CPU +
+ {s.cpu_pct.toFixed(1)}% +
+
+
+
+
+ +
+ Memory +
+ {mp.toFixed(1)}% +
+
+
+
+
+ +
+ Network +
+ {formatRate(totalRecvRate(s))} + {formatRate(totalSentRate(s))} +
+
+ {:else} + + {#each [0, 1, 2] as _} +
+ +
+ {/each} + {/if} +
+ + {#if agent._expanded} +
+ + {#if !agent.stats} + +
+
+ Waiting for first snapshot… +
+ + {:else} + {@const s = agent.stats} + + +
+ + +
+
+ CPU + + + +
+
+ {s.cpu_pct.toFixed(1)}% +
+
+
+
+ {#if s.cpu_per_core?.length > 0} +
+ {#each s.cpu_per_core as core, i} +
+
+
+
+
+ {/each} +
+ {/if} +
+ + + {#each [memPct(s)] as mp} +
+
+ Memory + + + +
+
+ {mp.toFixed(1)}% +
+
+
+
+
+ {formatBytes(s.mem_used)} / {formatBytes(s.mem_total)} +
+
+ {/each} + + +
+
+ Network + + + +
+
+
+ ↓ Recv + {formatRate(totalRecvRate(s))} +
+
+ ↑ Send + {formatRate(totalSentRate(s))} +
+
+
+ {s.net_interfaces.length} interface{s.net_interfaces.length !== 1 ? "s" : ""} +
+
+ + +
+
+ Processes + + + +
+
+ {s.processes.length} +
+
Running processes
+ {#if s.processes.length > 0} + {@const top = s.processes.slice().sort((a, b) => b.cpu_pct - a.cpu_pct)[0]} +
+ Top: {top.name} + {top.cpu_pct.toFixed(1)}% +
+ {/if} +
+ +
+ + + {#if s.net_interfaces.length > 0} +
+

Network Interfaces

+
+
+ + + + + + + + + + + + {#each s.net_interfaces as iface (iface.name)} + + + + + + + + {/each} + +
Interface↓ Recv rate↑ Send rateTotal recvTotal sent
{iface.name}{formatRate(iface.bytes_recv_rate)}{formatRate(iface.bytes_sent_rate)}{formatBytes(iface.bytes_recv)}{formatBytes(iface.bytes_sent)}
+
+
+
+ {/if} + + + {#if s.disks.length > 0} +
+

Disks

+
+ {#each s.disks as disk (disk.path)} + {@const diskPct = disk.total > 0 ? Math.min(100, (disk.used / disk.total) * 100) : 0} +
+
+ {disk.path} + {diskPct.toFixed(0)}% +
+
+
+
+
+ {formatBytes(disk.used)} used + {formatBytes(disk.total)} +
+
+ {/each} +
+
+ {/if} + + + {#if s.processes.length > 0} +
+

+ Top Processes (by CPU) +

+
+
+ + + + + + + + + + + + {#each topProcesses(s) as proc (proc.pid)} + + + + + + + + {/each} + +
PIDNameCMDCPU%RAM
{proc.pid}{proc.name}{proc.cmd} + {proc.cpu_pct.toFixed(1)}% + {formatBytes(proc.mem_rss)}
+
+
+
+ {/if} + + {/if} +
+ {/if} + +
+ {/each} + {/if} + +
+