feat: add proxy and statistics features

This commit is contained in:
2026-05-27 07:41:06 +02:00
parent 35643b2ea9
commit b3c7e67b78
23 changed files with 2139 additions and 147 deletions

View File

@ -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

84
agent/Cargo.lock generated
View File

@ -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"

View File

@ -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

View File

@ -241,6 +241,26 @@ impl ContainerBackend for DockerClient {
async fn list_networks(&self) -> Result<Vec<NetworkInfo>> {
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::<String> {
all: true,
..Default::default()
}))
.await
.unwrap_or_default();
let used_network_ids: std::collections::HashSet<String> = 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::<ListNetworksOptions<String>>)
@ -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();

View File

@ -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<String, JoinHandle<()>> = 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<String> = 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(())
}

242
agent/src/stats.rs Normal file
View File

@ -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<String, (u64, u64)>,
/// 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<f64> = 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<ProcessInfo> = sys
.processes()
.values()
.map(|p| {
let cmd = p
.cmd()
.iter()
.map(|s| s.to_string_lossy())
.collect::<Vec<_>>()
.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<NetworkInterfaceStats> = 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<DiskStats> = 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<f64> = 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");
}
}
}

View File

@ -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"

45
docker-compose.local.yml Normal file
View File

@ -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:

View File

@ -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:

View File

@ -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;
}
}

View File

@ -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)

View File

@ -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)
})
})

View File

@ -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 != "" {

View File

@ -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()

173
web/src/lib/Sidebar.svelte Normal file
View File

@ -0,0 +1,173 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { clearToken } from '$lib/auth';
function logout() {
clearToken();
goto('/login');
}
// Mobile sidebar toggle
let mobileOpen = $state(false);
function isActive(path: string): boolean {
if (path === '/') return $page.url.pathname === '/';
return $page.url.pathname.startsWith(path);
}
</script>
<!-- Mobile top bar (visible on small screens) -->
<div class="md:hidden glass sticky top-0 z-50 px-4 py-3 flex items-center gap-3">
<button
onclick={() => (mobileOpen = !mobileOpen)}
class="p-1.5 rounded-lg text-slate-400 hover:text-slate-200 hover:bg-white/[0.06] transition-colors"
aria-label="Menu"
>
{#if mobileOpen}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
{:else}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
{/if}
</button>
<div class="flex items-center gap-2">
<img src="/icon-192.png" alt="Nexarr" class="w-6 h-6 rounded-md" />
<span class="font-semibold text-slate-100 tracking-tight">Nexarr</span>
</div>
</div>
<!-- Mobile overlay -->
{#if mobileOpen}
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div
class="md:hidden fixed inset-0 z-40 bg-black/60 backdrop-blur-sm"
onclick={() => (mobileOpen = false)}
></div>
{/if}
<!-- Sidebar -->
<aside
class="
fixed md:static inset-y-0 left-0 z-50
w-56 md:w-52 lg:w-56 h-screen shrink-0
flex flex-col
bg-[#080c18] border-r border-white/[0.06]
transition-transform duration-200
{mobileOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}
"
>
<!-- Logo -->
<div class="px-5 py-5 flex items-center gap-2.5 border-b border-white/[0.06] shrink-0">
<img src="/icon-192.png" alt="Nexarr" class="w-7 h-7 rounded-lg" />
<span class="font-bold text-slate-100 tracking-tight text-base">Nexarr</span>
</div>
<!-- Nav -->
<nav class="flex-1 px-3 py-4 flex flex-col gap-0.5 overflow-y-auto">
<!-- Containers -->
<a
href="/"
onclick={() => (mobileOpen = false)}
class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors
{isActive('/') && !isActive('/compose') && !isActive('/stats') && !isActive('/proxy') && !isActive('/admin')
? 'bg-cyan-400/10 text-cyan-400 border border-cyan-400/20'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/[0.05]'}"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
Containers
</a>
<!-- Compose (sub-item) -->
<a
href="/compose"
onclick={() => (mobileOpen = false)}
class="flex items-center gap-2.5 pl-8 pr-3 py-1.5 rounded-lg text-xs font-medium transition-colors
{isActive('/compose')
? 'bg-cyan-400/10 text-cyan-400 border border-cyan-400/20'
: 'text-slate-500 hover:text-slate-300 hover:bg-white/[0.04]'}"
>
<svg class="w-3.5 h-3.5 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Compose Editor
</a>
<!-- Statistics -->
<a
href="/stats"
onclick={() => (mobileOpen = false)}
class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors
{isActive('/stats')
? 'bg-cyan-400/10 text-cyan-400 border border-cyan-400/20'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/[0.05]'}"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Statistics
</a>
<!-- Reverse Proxy -->
<a
href="/proxy"
onclick={() => (mobileOpen = false)}
class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors
{isActive('/proxy')
? 'bg-cyan-400/10 text-cyan-400 border border-cyan-400/20'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/[0.05]'}"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
Reverse Proxy
</a>
</nav>
<!-- Bottom section -->
<div class="px-3 pb-4 flex flex-col gap-0.5 border-t border-white/[0.06] pt-3 shrink-0">
<!-- Administration -->
<a
href="/admin"
onclick={() => (mobileOpen = false)}
class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors
{isActive('/admin')
? 'bg-cyan-400/10 text-cyan-400 border border-cyan-400/20'
: 'text-slate-400 hover:text-slate-200 hover:bg-white/[0.05]'}"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Administration
</a>
<!-- Logout -->
<button
onclick={logout}
class="flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors
text-slate-500 hover:text-signal-red hover:bg-signal-red/10 w-full text-left"
>
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h6a2 2 0 012 2v1" />
</svg>
Déconnexion
</button>
</div>
</aside>

View File

@ -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<AgentStats[]> {
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 {

View File

@ -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));
</script>
{@render children()}
{#if isPublic}
{@render children()}
{:else}
<div class="flex h-screen overflow-hidden bg-abyss-900">
<Sidebar />
<main class="flex-1 overflow-auto min-w-0">
{@render children()}
</main>
</div>
{/if}

View File

@ -5,7 +5,6 @@
prompt(): Promise<void>;
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<Tab>("containers");
@ -731,7 +724,7 @@
</script>
<svelte:head>
<title>Containarr</title>
<title>Nexarr</title>
</svelte:head>
{#if logTarget}
@ -758,38 +751,21 @@
<div class="min-h-screen bg-abyss-900 bg-grid-faint bg-grid text-slate-200" role="presentation" onclick={closeAutoUpdateOnClickOutside}>
<!-- Header -->
<!-- Top bar: stats + actions contextuel -->
<header class="glass sticky top-0 z-40 px-5 py-3 flex items-center gap-3">
<div class="flex items-center gap-2.5">
<img src="/icon-192.png" alt="Containarr" class="w-6 h-6 rounded-md" />
<span class="font-semibold text-slate-100 tracking-tight">Containarr</span>
<div class="flex items-center gap-3 text-xs text-slate-500 tabular-nums">
{#if activeTab === "containers" && entries !== null}
<span>{entries.length} containers · {Object.keys(byAgent).length} hosts</span>
{:else if activeTab === "images" && images !== null}
<span>{images.length} images · {Object.keys(byAgentImages).length} hosts</span>
{:else if activeTab === "volumes" && volumes !== null}
<span>{volumes.length} volumes · {Object.keys(byAgentVolumes).length} hosts</span>
{:else if activeTab === "networks" && networks !== null}
<span>{networks.length} networks · {Object.keys(byAgentNetworks).length} hosts</span>
{/if}
</div>
<div class="ml-auto flex items-center gap-1">
{#if activeTab === "containers" && entries !== null}
<span class="hidden sm:inline text-xs text-slate-500 mr-3 tabular-nums">
{entries.length} containers · {Object.keys(byAgent).length} hosts
</span>
{:else if activeTab === "images"}
{#if images !== null}
<span class="hidden sm:inline text-xs text-slate-500 mr-3 tabular-nums">
{images.length} images · {Object.keys(byAgentImages).length} hosts
</span>
{/if}
{:else if activeTab === "volumes"}
{#if volumes !== null}
<span class="hidden sm:inline text-xs text-slate-500 mr-3 tabular-nums">
{volumes.length} volumes · {Object.keys(byAgentVolumes).length} hosts
</span>
{/if}
{:else if activeTab === "networks"}
{#if networks !== null}
<span class="hidden sm:inline text-xs text-slate-500 mr-3 tabular-nums">
{networks.length} networks · {Object.keys(byAgentNetworks).length} hosts
</span>
{/if}
{/if}
{#if installPrompt}
<button onclick={installPWA} class="nav-btn" title="Installer l'application">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -799,34 +775,12 @@
</button>
{/if}
<a href="/compose" class="nav-btn" title="Éditeur Compose">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</a>
<a href="/admin" class="nav-btn" title="Administration">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</a>
<button onclick={loadActiveTab} class="nav-btn" title="Actualiser">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
<button onclick={logout} class="nav-btn" title="Déconnexion">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h6a2 2 0 012 2v1" />
</svg>
</button>
</div>
</header>

View File

@ -113,7 +113,7 @@
</script>
<svelte:head>
<title>Admin — Containarr</title>
<title>Admin — Nexarr</title>
</svelte:head>
{#if toast}
@ -129,21 +129,17 @@
<div class="min-h-screen bg-abyss-900 bg-grid-faint bg-grid text-slate-200">
<!-- Header -->
<header class="glass sticky top-0 z-40 px-5 py-3 flex items-center gap-3">
<div class="flex items-center gap-2.5">
<img src="/icon-192.png" alt="Containarr" class="w-6 h-6 rounded-md" />
<span class="font-semibold text-slate-100 tracking-tight">Containarr</span>
<span class="text-slate-700">/</span>
<span class="text-sm text-slate-400">Administration</span>
</div>
<a href="/" class="ml-auto nav-btn flex items-center gap-1.5 text-xs text-slate-500 hover:text-slate-200 px-3">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
<!-- Page title bar -->
<div class="border-b border-white/[0.06] bg-abyss-900/80 px-4 md:px-6 py-3 shrink-0">
<div class="max-w-4xl mx-auto flex items-center gap-2">
<svg class="w-4 h-4 text-cyan-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
Retour
</a>
</header>
<span class="text-sm font-medium text-slate-300">Administration</span>
</div>
</div>
<main class="p-4 md:p-6 max-w-4xl mx-auto space-y-10">

View File

@ -1,6 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { goto } from '$app/navigation';
import { EditorState } from '@codemirror/state';
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
import { defaultKeymap, indentWithTab } from '@codemirror/commands';
@ -8,14 +7,7 @@
import { oneDark } from '@codemirror/theme-one-dark';
import { indentUnit } from '@codemirror/language';
import { bracketMatching } from '@codemirror/language';
import { fetchAgents, fsList, fsRead, fsWrite, composeAction, fsMkdir, type Agent } from '$lib/api';
import { clearToken } from '$lib/auth';
// ── Auth ──────────────────────────────────────────────────────────────────
function logout() {
clearToken();
goto('/login');
}
import { fetchAgents, fetchContainers, fetchNetworks, fsList, fsRead, fsWrite, composeAction, fsMkdir, type Agent } from '$lib/api';
// ── Toast ─────────────────────────────────────────────────────────────────
let toast = $state<{ msg: string; ok: boolean } | null>(null);
@ -68,10 +60,12 @@
return a.name.localeCompare(b.name);
});
currentPath = path;
// auto-suggest compose file if present in this directory
// auto-suggest compose file if present, otherwise pre-fill with docker-compose.yml
const compose = entries.find(e => !e.is_dir && isComposeName(e.name));
if (compose) {
filePath = (path === '/' ? '' : path) + '/' + compose.name;
} else {
filePath = (path === '/' ? '' : path) + '/docker-compose.yml';
}
} catch (e: unknown) {
browseError = e instanceof Error ? e.message : String(e);
@ -213,12 +207,222 @@
});
}
// ── insertAtCursor ────────────────────────────────────────────────────────
function insertAtCursor(text: string) {
if (!view) return;
const { from } = view.state.selection.main;
view.dispatch({
changes: { from, to: from, insert: text },
selection: { anchor: from + text.length },
});
view.focus();
}
// ── Tools panel ───────────────────────────────────────────────────────────
let openTool = $state<'path' | 'timezone' | 'network' | 'port' | 'name' | null>(null);
function toggleTool(t: typeof openTool) { openTool = openTool === t ? null : t; }
// Tool: Path mini file browser
let toolPath = $state('/');
let toolDirEntries = $state<{ name: string; is_dir: boolean }[]>([]);
let toolBrowseLoading = $state(false);
let toolPathSearch = $state('');
async function toolBrowse(path: string) {
if (!selectedAgentId) return;
toolBrowseLoading = true;
try {
const entries = await fsList(selectedAgentId, path);
toolDirEntries = entries.sort((a, b) => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
return a.name.localeCompare(b.name);
});
toolPath = path;
toolPathSearch = '';
} catch {
toolDirEntries = [];
} finally {
toolBrowseLoading = false;
}
}
function toolEntryFullPath(name: string): string {
return toolPath === '/' ? '/' + name : toolPath + '/' + name;
}
function toolNavigateUp() {
if (toolPath === '/') return;
const parent = toolPath.substring(0, toolPath.lastIndexOf('/')) || '/';
toolBrowse(parent);
}
// Tool: Timezone
const TIMEZONES = [
{ city: 'Paris', tz: 'Europe/Paris' },
{ city: 'London', tz: 'Europe/London' },
{ city: 'Berlin', tz: 'Europe/Berlin' },
{ city: 'Madrid', tz: 'Europe/Madrid' },
{ city: 'Rome', tz: 'Europe/Rome' },
{ city: 'Amsterdam', tz: 'Europe/Amsterdam' },
{ city: 'Brussels', tz: 'Europe/Brussels' },
{ city: 'Zurich', tz: 'Europe/Zurich' },
{ city: 'Stockholm', tz: 'Europe/Stockholm' },
{ city: 'Oslo', tz: 'Europe/Oslo' },
{ city: 'Copenhagen', tz: 'Europe/Copenhagen' },
{ city: 'Helsinki', tz: 'Europe/Helsinki' },
{ city: 'Warsaw', tz: 'Europe/Warsaw' },
{ city: 'Prague', tz: 'Europe/Prague' },
{ city: 'Vienna', tz: 'Europe/Vienna' },
{ city: 'Budapest', tz: 'Europe/Budapest' },
{ city: 'Bucharest', tz: 'Europe/Bucharest' },
{ city: 'Athens', tz: 'Europe/Athens' },
{ city: 'Istanbul', tz: 'Europe/Istanbul' },
{ city: 'Moscow', tz: 'Europe/Moscow' },
{ city: 'Kiev', tz: 'Europe/Kiev' },
{ city: 'Minsk', tz: 'Europe/Minsk' },
{ city: 'Lisbon', tz: 'Europe/Lisbon' },
{ city: 'Dublin', tz: 'Europe/Dublin' },
{ city: 'New York', tz: 'America/New_York' },
{ city: 'Chicago', tz: 'America/Chicago' },
{ city: 'Denver', tz: 'America/Denver' },
{ city: 'Los Angeles', tz: 'America/Los_Angeles' },
{ city: 'Toronto', tz: 'America/Toronto' },
{ city: 'Vancouver', tz: 'America/Vancouver' },
{ city: 'Montreal', tz: 'America/Montreal' },
{ city: 'Mexico City', tz: 'America/Mexico_City' },
{ city: 'São Paulo', tz: 'America/Sao_Paulo' },
{ city: 'Buenos Aires', tz: 'America/Argentina/Buenos_Aires' },
{ city: 'Santiago', tz: 'America/Santiago' },
{ city: 'Bogota', tz: 'America/Bogota' },
{ city: 'Lima', tz: 'America/Lima' },
{ city: 'Caracas', tz: 'America/Caracas' },
{ city: 'Havana', tz: 'America/Havana' },
{ city: 'Anchorage', tz: 'America/Anchorage' },
{ city: 'Honolulu', tz: 'Pacific/Honolulu' },
{ city: 'Dubai', tz: 'Asia/Dubai' },
{ city: 'Riyadh', tz: 'Asia/Riyadh' },
{ city: 'Tehran', tz: 'Asia/Tehran' },
{ city: 'Karachi', tz: 'Asia/Karachi' },
{ city: 'Mumbai', tz: 'Asia/Kolkata' },
{ city: 'Kolkata', tz: 'Asia/Kolkata' },
{ city: 'Dhaka', tz: 'Asia/Dhaka' },
{ city: 'Bangkok', tz: 'Asia/Bangkok' },
{ city: 'Jakarta', tz: 'Asia/Jakarta' },
{ city: 'Singapore', tz: 'Asia/Singapore' },
{ city: 'Kuala Lumpur', tz: 'Asia/Kuala_Lumpur' },
{ city: 'Manila', tz: 'Asia/Manila' },
{ city: 'Hong Kong', tz: 'Asia/Hong_Kong' },
{ city: 'Shanghai', tz: 'Asia/Shanghai' },
{ city: 'Beijing', tz: 'Asia/Shanghai' },
{ city: 'Taipei', tz: 'Asia/Taipei' },
{ city: 'Seoul', tz: 'Asia/Seoul' },
{ city: 'Tokyo', tz: 'Asia/Tokyo' },
{ city: 'Osaka', tz: 'Asia/Tokyo' },
{ city: 'Vladivostok', tz: 'Asia/Vladivostok' },
{ city: 'Yekaterinburg', tz: 'Asia/Yekaterinburg' },
{ city: 'Novosibirsk', tz: 'Asia/Novosibirsk' },
{ city: 'Almaty', tz: 'Asia/Almaty' },
{ city: 'Tashkent', tz: 'Asia/Tashkent' },
{ city: 'Tbilisi', tz: 'Asia/Tbilisi' },
{ city: 'Baku', tz: 'Asia/Baku' },
{ city: 'Colombo', tz: 'Asia/Colombo' },
{ city: 'Kathmandu', tz: 'Asia/Kathmandu' },
{ city: 'Yangon', tz: 'Asia/Rangoon' },
{ city: 'Cairo', tz: 'Africa/Cairo' },
{ city: 'Nairobi', tz: 'Africa/Nairobi' },
{ city: 'Lagos', tz: 'Africa/Lagos' },
{ city: 'Johannesburg', tz: 'Africa/Johannesburg' },
{ city: 'Casablanca', tz: 'Africa/Casablanca' },
{ city: 'Accra', tz: 'Africa/Accra' },
{ city: 'Tunis', tz: 'Africa/Tunis' },
{ city: 'Sydney', tz: 'Australia/Sydney' },
{ city: 'Melbourne', tz: 'Australia/Melbourne' },
{ city: 'Brisbane', tz: 'Australia/Brisbane' },
{ city: 'Perth', tz: 'Australia/Perth' },
{ city: 'Auckland', tz: 'Pacific/Auckland' },
{ city: 'Fiji', tz: 'Pacific/Fiji' },
{ city: 'UTC', tz: 'UTC' },
];
let tzSearch = $state('');
const filteredTz = $derived(
tzSearch.trim() === ''
? TIMEZONES
: TIMEZONES.filter(t =>
t.city.toLowerCase().includes(tzSearch.toLowerCase()) ||
t.tz.toLowerCase().includes(tzSearch.toLowerCase())
)
);
// Tool: Networks
let agentNetworks = $state<Array<{ id: string; name: string; driver: string }>>([]);
let networksLoaded = $state(false);
async function loadAgentNetworks() {
if (!selectedAgentId || networksLoaded) return;
try {
const all = await fetchNetworks();
agentNetworks = all
.filter(n => n.agent_id === selectedAgentId)
.map(n => ({ id: n.id, name: n.name, driver: n.driver }));
networksLoaded = true;
} catch {}
}
// Tool: Ports
let usedPorts = $state<Array<{ port: number; container: string }>>([]);
let portsLoaded = $state(false);
let portSearch = $state('');
let portInput = $state('');
const portConflict = $derived(
portInput ? usedPorts.find(p => p.port === parseInt(portInput)) : null
);
async function loadUsedPorts() {
if (!selectedAgentId || portsLoaded) return;
try {
const all = await fetchContainers();
usedPorts = all
.filter(e => e.agent_id === selectedAgentId)
.flatMap(e => (e.container.ports ?? []).map(p => ({ port: p.host_port, container: e.container.name })))
.filter(p => p.port > 0)
.sort((a, b) => a.port - b.port);
portsLoaded = true;
} catch {}
}
// Tool: Container names
let usedNames = $state<string[]>([]);
let namesLoaded = $state(false);
let nameSearch = $state('');
let nameInput = $state('');
const nameConflict = $derived(
nameInput.trim() ? usedNames.includes(nameInput.trim()) : null
);
async function loadUsedNames() {
if (!selectedAgentId || namesLoaded) return;
try {
const all = await fetchContainers();
usedNames = all
.filter(e => e.agent_id === selectedAgentId)
.map(e => e.container.name)
.sort();
namesLoaded = true;
} catch {}
}
// ── Agent change ──────────────────────────────────────────────────────────
function onAgentChange() {
dirEntries = [];
currentPath = '/opt';
filePath = '';
composeOutput = '';
networksLoaded = false;
portsLoaded = false;
namesLoaded = false;
agentNetworks = [];
usedPorts = [];
usedNames = [];
if (selectedAgentId) browse('/opt');
}
@ -262,7 +466,7 @@
</script>
<svelte:head>
<title>Compose — Containarr</title>
<title>Compose — Nexarr</title>
</svelte:head>
<!-- Toast -->
@ -279,38 +483,6 @@
<div class="min-h-screen bg-abyss-900 bg-grid-faint bg-grid text-slate-200 flex flex-col">
<!-- Header -->
<header class="glass sticky top-0 z-40 px-5 py-3 flex items-center gap-3 shrink-0">
<div class="flex items-center gap-2.5">
<img src="/icon-192.png" alt="Containarr" class="w-6 h-6 rounded-md" />
<span class="font-semibold text-slate-100 tracking-tight">Containarr</span>
</div>
<div class="ml-auto flex items-center gap-1">
<a href="/" class="nav-btn" title="Dashboard">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
</a>
<a href="/admin" class="nav-btn" title="Administration">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</a>
<button onclick={logout} class="nav-btn" title="Déconnexion">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h6a2 2 0 012 2v1" />
</svg>
</button>
</div>
</header>
<!-- Page title bar -->
<div class="border-b border-white/[0.06] bg-abyss-900/80 px-4 md:px-6 py-3 shrink-0">
<div class="max-w-screen-2xl mx-auto flex items-center gap-2">
@ -532,6 +704,341 @@
/>
</div>
<!-- Tools panel -->
<div class="card p-3 flex flex-col gap-2">
<span class="text-xs font-medium text-slate-500 uppercase tracking-wider">Outils</span>
<!-- Tool 1: Path / Bind mount -->
<div class="flex flex-col gap-1">
<button
onclick={() => {
const wasOpen = openTool === 'path';
toggleTool('path');
if (!wasOpen && toolDirEntries.length === 0) toolBrowse('/');
}}
class="flex items-center justify-between w-full text-xs font-medium text-slate-400 hover:text-slate-200 transition-colors"
>
<span class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
Chemin
</span>
<svg class="w-3 h-3 transition-transform {openTool === 'path' ? 'rotate-180' : ''}"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if openTool === 'path'}
<div class="space-y-1.5 mt-1">
<!-- Breadcrumb + bouton remonter -->
<div class="flex items-center gap-1 min-w-0">
<button
onclick={toolNavigateUp}
disabled={toolPath === '/'}
class="shrink-0 p-0.5 rounded text-slate-500 hover:text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
title="Remonter"
>
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span class="font-mono text-xs text-slate-500 truncate flex-1" title={toolPath}>{toolPath}</span>
{#if toolBrowseLoading}
<div class="w-2.5 h-2.5 border border-cyan-400/40 border-t-cyan-400 rounded-full animate-spin shrink-0"></div>
{/if}
</div>
<!-- Bouton insérer dossier courant -->
<button
onclick={() => insertAtCursor(toolPath)}
class="w-full text-left px-2 py-1 text-xs font-mono text-cyan-400 hover:bg-cyan-400/10 rounded transition-colors truncate"
title="Insérer ce chemin"
>
<span class="opacity-70">{toolPath}</span>
</button>
<!-- Filtre -->
<input
type="text"
bind:value={toolPathSearch}
placeholder="Filtrer…"
class="w-full bg-abyss-800 border border-white/[0.08] rounded-md px-2 py-1
text-xs font-mono text-slate-200 placeholder-slate-600
focus:outline-none focus:border-cyan-400/40 transition-colors"
/>
<!-- Liste dossiers et fichiers -->
<div class="overflow-y-auto max-h-48 space-y-0.5">
{#each toolDirEntries.filter(e => toolPathSearch.trim() === '' || e.name.toLowerCase().includes(toolPathSearch.toLowerCase())) as entry}
{#if entry.is_dir}
<!-- Dossier : naviguer dedans OU insérer avec clic droit (on fait deux boutons) -->
<div class="flex items-center gap-1 group">
<button
onclick={() => toolBrowse(toolEntryFullPath(entry.name))}
class="flex items-center gap-1.5 flex-1 min-w-0 text-left px-2 py-1 text-xs font-mono
text-slate-300 hover:text-slate-100 hover:bg-white/[0.05] rounded transition-colors"
>
<svg class="w-3 h-3 shrink-0 text-cyan-400/50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
<span class="truncate">{entry.name}</span>
</button>
<!-- Bouton insérer le chemin de ce dossier -->
<button
onclick={() => insertAtCursor(toolEntryFullPath(entry.name))}
class="shrink-0 px-1.5 py-1 text-xs text-slate-600 hover:text-cyan-400 opacity-0 group-hover:opacity-100 transition-all rounded hover:bg-cyan-400/10"
title="Insérer ce chemin"
></button>
</div>
{:else}
<!-- Fichier : insérer le chemin -->
<button
onclick={() => insertAtCursor(toolEntryFullPath(entry.name))}
class="flex items-center gap-1.5 w-full text-left px-2 py-1 text-xs font-mono
text-slate-500 hover:text-slate-300 hover:bg-white/[0.05] rounded transition-colors"
>
<svg class="w-3 h-3 shrink-0 text-slate-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span class="truncate">{entry.name}</span>
</button>
{/if}
{/each}
{#if toolDirEntries.length === 0 && !toolBrowseLoading}
<p class="text-xs text-slate-600 italic px-2 py-1">Dossier vide</p>
{/if}
</div>
</div>
{/if}
</div>
<div class="border-t border-white/[0.05]"></div>
<!-- Tool 2: Timezone -->
<div class="flex flex-col gap-1">
<button
onclick={() => toggleTool('timezone')}
class="flex items-center justify-between w-full text-xs font-medium text-slate-400 hover:text-slate-200 transition-colors"
>
<span class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
Timezone
</span>
<svg class="w-3 h-3 transition-transform {openTool === 'timezone' ? 'rotate-180' : ''}"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if openTool === 'timezone'}
<div class="space-y-1 mt-1">
<input
type="text"
bind:value={tzSearch}
placeholder="Rechercher…"
class="w-full bg-abyss-800 border border-white/[0.08] rounded-md px-2 py-1
text-xs font-mono text-slate-200 placeholder-slate-600
focus:outline-none focus:border-cyan-400/40 transition-colors"
/>
<div class="max-h-40 overflow-y-auto space-y-0.5">
{#each filteredTz as tz}
<button
onclick={() => insertAtCursor(tz.tz)}
class="w-full text-left px-2 py-1 text-xs rounded hover:bg-white/[0.05] transition-colors flex items-center justify-between gap-1"
>
<span class="text-slate-300 truncate">{tz.city}</span>
<span class="text-slate-500 font-mono text-[10px] truncate shrink-0">{tz.tz}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
<div class="border-t border-white/[0.05]"></div>
<!-- Tool 3: Networks -->
<div class="flex flex-col gap-1">
<button
onclick={() => { toggleTool('network'); if (openTool === 'network') loadAgentNetworks(); }}
class="flex items-center justify-between w-full text-xs font-medium text-slate-400 hover:text-slate-200 transition-colors"
>
<span class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<circle cx="12" cy="5" r="2" stroke="currentColor" stroke-width="1.75" />
<circle cx="5" cy="19" r="2" stroke="currentColor" stroke-width="1.75" />
<circle cx="19" cy="19" r="2" stroke="currentColor" stroke-width="1.75" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M12 7v4m0 0l-5 6m5-6l5 6" />
</svg>
Networks
</span>
<svg class="w-3 h-3 transition-transform {openTool === 'network' ? 'rotate-180' : ''}"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if openTool === 'network'}
<div class="space-y-0.5 mt-1">
{#if agentNetworks.length === 0}
<p class="text-xs text-slate-600 italic px-1">Aucun network</p>
{/if}
{#each agentNetworks.filter(n => n.name !== 'bridge' && n.name !== 'host' && n.name !== 'none') as net}
<button
onclick={() => insertAtCursor(net.name)}
class="w-full text-left px-2 py-1 text-xs rounded hover:bg-white/[0.05] transition-colors flex items-center justify-between gap-1"
>
<span class="text-slate-300 font-mono truncate">{net.name}</span>
<span class="text-[10px] px-1 py-0.5 rounded bg-white/[0.06] text-slate-500 shrink-0">{net.driver}</span>
</button>
{/each}
</div>
{/if}
</div>
<div class="border-t border-white/[0.05]"></div>
<!-- Tool 4: Ports -->
<div class="flex flex-col gap-1">
<button
onclick={() => { toggleTool('port'); if (openTool === 'port') loadUsedPorts(); }}
class="flex items-center justify-between w-full text-xs font-medium text-slate-400 hover:text-slate-200 transition-colors"
>
<span class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M5 12h14M12 5l7 7-7 7" />
</svg>
Ports
</span>
<svg class="w-3 h-3 transition-transform {openTool === 'port' ? 'rotate-180' : ''}"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if openTool === 'port'}
<div class="space-y-1.5 mt-1">
<!-- Port tester -->
<div>
<input
type="number"
bind:value={portInput}
placeholder="Tester un port…"
class="w-full bg-abyss-800 border border-white/[0.08] rounded-md px-2 py-1
text-xs font-mono text-slate-200 placeholder-slate-600
focus:outline-none focus:border-cyan-400/40 transition-colors"
/>
{#if portInput}
{#if portConflict}
<p class="text-[10px] mt-0.5 text-signal-red px-1">❌ Utilisé par {portConflict.container}</p>
{:else}
<p class="text-[10px] mt-0.5 text-signal-green px-1">✓ Disponible</p>
{/if}
{/if}
</div>
<!-- Port search -->
<input
type="text"
bind:value={portSearch}
placeholder="Filtrer les ports…"
class="w-full bg-abyss-800 border border-white/[0.08] rounded-md px-2 py-1
text-xs font-mono text-slate-200 placeholder-slate-600
focus:outline-none focus:border-cyan-400/40 transition-colors"
/>
<div class="max-h-40 overflow-y-auto space-y-0.5">
{#if usedPorts.length === 0}
<p class="text-xs text-slate-600 italic px-1">Aucun port utilisé</p>
{/if}
{#each usedPorts.filter(p => portSearch.trim() === '' || String(p.port).includes(portSearch)) as p}
<button
onclick={() => insertAtCursor(`${p.port}:${p.port}`)}
class="w-full text-left px-2 py-1 text-xs rounded hover:bg-white/[0.05] transition-colors flex items-center gap-2"
>
<span class="text-[10px] px-1 py-0.5 rounded bg-signal-red/15 text-signal-red border border-signal-red/25 font-mono shrink-0">{p.port}</span>
<span class="text-slate-400 truncate">{p.container}</span>
</button>
{/each}
</div>
</div>
{/if}
</div>
<div class="border-t border-white/[0.05]"></div>
<!-- Tool 5: Container names -->
<div class="flex flex-col gap-1">
<button
onclick={() => { toggleTool('name'); if (openTool === 'name') loadUsedNames(); }}
class="flex items-center justify-between w-full text-xs font-medium text-slate-400 hover:text-slate-200 transition-colors"
>
<span class="flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A2 2 0 013 12V7a4 4 0 014-4z" />
</svg>
Noms
</span>
<svg class="w-3 h-3 transition-transform {openTool === 'name' ? 'rotate-180' : ''}"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
{#if openTool === 'name'}
<div class="space-y-1.5 mt-1">
<!-- Name tester -->
<div>
<input
type="text"
bind:value={nameInput}
placeholder="Tester un nom…"
class="w-full bg-abyss-800 border border-white/[0.08] rounded-md px-2 py-1
text-xs font-mono text-slate-200 placeholder-slate-600
focus:outline-none focus:border-cyan-400/40 transition-colors"
/>
{#if nameInput.trim()}
{#if nameConflict}
<p class="text-[10px] mt-0.5 text-signal-red px-1">❌ Déjà utilisé</p>
{:else}
<p class="text-[10px] mt-0.5 text-signal-green px-1">✓ Disponible</p>
{/if}
{/if}
</div>
<!-- Name search -->
<input
type="text"
bind:value={nameSearch}
placeholder="Filtrer les noms…"
class="w-full bg-abyss-800 border border-white/[0.08] rounded-md px-2 py-1
text-xs font-mono text-slate-200 placeholder-slate-600
focus:outline-none focus:border-cyan-400/40 transition-colors"
/>
<div class="max-h-40 overflow-y-auto space-y-0.5">
{#if usedNames.length === 0}
<p class="text-xs text-slate-600 italic px-1">Aucun container</p>
{/if}
{#each usedNames.filter(n => nameSearch.trim() === '' || n.toLowerCase().includes(nameSearch.toLowerCase())) as name}
<button
onclick={() => insertAtCursor(name)}
class="w-full text-left px-2 py-1 text-xs font-mono text-slate-300 rounded hover:bg-white/[0.05] transition-colors truncate"
>
{name}
</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</aside>
<!-- ── Editor area ─────────────────────────────────────────────────────── -->

View File

@ -23,7 +23,7 @@
</script>
<svelte:head>
<title>Connexion — Containarr</title>
<title>Connexion — Nexarr</title>
</svelte:head>
<div class="min-h-screen bg-abyss-900 bg-grid-faint bg-grid flex items-center justify-center p-4">
@ -31,10 +31,10 @@
<!-- Logo -->
<div class="flex flex-col items-center gap-3 mb-8">
<img src="/icon-192.png" alt="Containarr" class="w-14 h-14 rounded-2xl shadow-glow-emerald" />
<img src="/icon-192.png" alt="Nexarr" class="w-14 h-14 rounded-2xl shadow-glow-emerald" />
<div class="text-center">
<h1 class="text-lg font-semibold text-slate-100 tracking-tight">Containarr</h1>
<p class="text-xs text-slate-600 mt-0.5">Gestionnaire de containers Docker</p>
<h1 class="text-lg font-semibold text-slate-100 tracking-tight">Nexarr</h1>
<p class="text-xs text-slate-600 mt-0.5">Infrastructure management dashboard</p>
</div>
</div>

View File

@ -0,0 +1,88 @@
<svelte:head>
<title>Reverse Proxy — Nexarr</title>
</svelte:head>
<div class="min-h-full bg-abyss-900 bg-grid-faint bg-grid text-slate-200">
<!-- Page title bar -->
<div class="border-b border-white/[0.06] bg-abyss-900/80 px-4 md:px-6 py-3 shrink-0">
<div class="max-w-screen-xl mx-auto flex items-center gap-2">
<svg class="w-4 h-4 text-cyan-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
<span class="text-sm font-medium text-slate-300">Reverse Proxy</span>
</div>
</div>
<main class="p-4 md:p-6 max-w-screen-xl mx-auto">
<!-- Feature preview cards -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-10">
<!-- Nginx -->
<div class="card p-5 flex flex-col gap-3 opacity-60">
<div class="w-9 h-9 rounded-xl bg-signal-green/10 border border-signal-green/20 flex items-center justify-center">
<svg class="w-5 h-5 text-signal-green" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M5 12h14M12 5l7 7-7 7" />
</svg>
</div>
<div>
<div class="text-sm font-semibold text-slate-300 mb-0.5">Nginx</div>
<div class="text-xs text-slate-600">Manage virtual hosts, upstreams and SSL termination</div>
</div>
</div>
<!-- Traefik -->
<div class="card p-5 flex flex-col gap-3 opacity-60">
<div class="w-9 h-9 rounded-xl bg-cyan-400/10 border border-cyan-400/20 flex items-center justify-center">
<svg class="w-5 h-5 text-cyan-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<div>
<div class="text-sm font-semibold text-slate-300 mb-0.5">Traefik</div>
<div class="text-xs text-slate-600">Dynamic routing rules and middlewares via labels</div>
</div>
</div>
<!-- Caddy -->
<div class="card p-5 flex flex-col gap-3 opacity-60">
<div class="w-9 h-9 rounded-xl bg-violet-400/10 border border-violet-400/20 flex items-center justify-center">
<svg class="w-5 h-5 text-violet-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
</div>
<div>
<div class="text-sm font-semibold text-slate-300 mb-0.5">Caddy</div>
<div class="text-xs text-slate-600">Automatic HTTPS with Caddyfile management</div>
</div>
</div>
</div>
<!-- Empty state -->
<div class="flex flex-col items-center justify-center py-24 gap-5 text-center">
<div class="w-16 h-16 rounded-2xl bg-cyan-400/5 border border-cyan-400/15 flex items-center justify-center">
<svg class="w-8 h-8 text-cyan-400/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<div class="space-y-1.5 max-w-sm">
<h2 class="text-base font-semibold text-slate-300">Coming soon</h2>
<p class="text-sm text-slate-600 leading-relaxed">
Manage your reverse proxy configurations per agent — create, edit and deploy Nginx, Traefik or Caddy configs directly from the UI.
</p>
</div>
<div class="flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-cyan-400/5 border border-cyan-400/10">
<span class="w-1.5 h-1.5 rounded-full bg-cyan-400/40 animate-pulse"></span>
<span class="text-xs text-cyan-400/60 font-medium">In development</span>
</div>
</div>
</main>
</div>

View File

@ -0,0 +1,547 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import {
fetchAllStats,
connectEvents,
type AgentStats,
type AgentStatsSnapshot,
} from "$lib/api";
// ── State ─────────────────────────────────────────────────────────────────
type AgentStatsUI = AgentStats & { _expanded: boolean };
let statsList = $state<AgentStatsUI[] | null>(null);
let loadError = $state<string | null>(null);
let refreshTimer: ReturnType<typeof setInterval> | null = null;
let disconnect: (() => void) | null = null;
// ── Helpers ───────────────────────────────────────────────────────────────
function formatBytes(n: number): string {
if (n >= 1_099_511_627_776) return `${(n / 1_099_511_627_776).toFixed(2)} TB`;
if (n >= 1_073_741_824) return `${(n / 1_073_741_824).toFixed(2)} GB`;
if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB`;
if (n >= 1024) return `${(n / 1024).toFixed(0)} KB`;
return `${n} B`;
}
function formatRate(n: number): string {
if (n >= 1_073_741_824) return `${(n / 1_073_741_824).toFixed(2)} GB/s`;
if (n >= 1_048_576) return `${(n / 1_048_576).toFixed(1)} MB/s`;
if (n >= 1024) return `${(n / 1024).toFixed(0)} KB/s`;
return `${n} B/s`;
}
function cpuColor(pct: number): string {
if (pct > 90) return "text-red-400";
if (pct > 70) return "text-amber-400";
return "text-cyan-400";
}
function cpuBarColor(pct: number): string {
if (pct > 90) return "bg-red-400";
if (pct > 70) return "bg-amber-400";
return "bg-cyan-400";
}
function memColor(pct: number): string {
if (pct > 90) return "text-red-400";
if (pct > 70) return "text-amber-400";
return "text-violet-400";
}
function memBarColor(pct: number): string {
if (pct > 90) return "bg-red-400";
if (pct > 70) return "bg-amber-400";
return "bg-violet-400";
}
function diskBarColor(pct: number): string {
if (pct > 90) return "bg-red-400";
if (pct > 70) return "bg-amber-400";
return "bg-emerald-400";
}
function memPct(s: AgentStatsSnapshot): number {
if (!s.mem_total) return 0;
return Math.min(100, (s.mem_used / s.mem_total) * 100);
}
function topProcesses(s: AgentStatsSnapshot, n = 20) {
return [...s.processes]
.sort((a, b) => b.cpu_pct - a.cpu_pct)
.slice(0, n);
}
function totalRecvRate(s: AgentStatsSnapshot): number {
return s.net_interfaces.reduce((sum, i) => sum + i.bytes_recv_rate, 0);
}
function totalSentRate(s: AgentStatsSnapshot): number {
return s.net_interfaces.reduce((sum, i) => sum + i.bytes_sent_rate, 0);
}
// ── Normalize snapshot (backend omits zero-value fields with omitempty) ──
function normalizeSnapshot(s: AgentStatsSnapshot | null): AgentStatsSnapshot | null {
if (!s) return null;
return {
cpu_pct: s.cpu_pct ?? 0,
cpu_per_core: s.cpu_per_core ?? [],
mem_total: s.mem_total ?? 0,
mem_used: s.mem_used ?? 0,
mem_available: s.mem_available ?? 0,
net_interfaces: (s.net_interfaces ?? []).map(i => ({
name: i.name ?? '',
bytes_recv: i.bytes_recv ?? 0,
bytes_sent: i.bytes_sent ?? 0,
bytes_recv_rate: i.bytes_recv_rate ?? 0,
bytes_sent_rate: i.bytes_sent_rate ?? 0,
})),
processes: (s.processes ?? []).map(p => ({
pid: p.pid ?? 0,
name: p.name ?? '',
cmd: p.cmd ?? '',
cpu_pct: p.cpu_pct ?? 0,
mem_rss: p.mem_rss ?? 0,
})),
disks: (s.disks ?? []).map(d => ({
path: d.path ?? '',
total: d.total ?? 0,
used: d.used ?? 0,
free: d.free ?? 0,
})),
timestamp: s.timestamp ?? 0,
};
}
// ── Toggle collapse ───────────────────────────────────────────────────────
function toggleSection(agentId: string) {
if (!statsList) return;
statsList = statsList.map(a =>
a.agent_id === agentId ? { ...a, _expanded: !a._expanded } : a
);
}
// ── Load & WebSocket ──────────────────────────────────────────────────────
async function load() {
loadError = null;
try {
const data = await fetchAllStats();
// Préserver l'état _expanded des agents déjà présents
const prevExpanded: Record<string, boolean> = {};
if (statsList) {
for (const a of statsList) prevExpanded[a.agent_id] = a._expanded;
}
statsList = data.map(a => ({
...a,
stats: normalizeSnapshot(a.stats),
_expanded: prevExpanded[a.agent_id] ?? false,
}));
} catch (e: unknown) {
loadError = e instanceof Error ? e.message : String(e);
statsList = [];
}
}
onMount(() => {
load();
// Auto-refresh toutes les 30s
refreshTimer = setInterval(load, 30_000);
// WebSocket: écouter stats.updated pour mise à jour en temps réel
disconnect = connectEvents((evt) => {
if (evt.type === "stats.updated" && evt.agent_id) {
const payload = evt.payload as AgentStatsSnapshot;
if (!statsList) return;
const idx = statsList.findIndex(a => a.agent_id === evt.agent_id);
if (idx >= 0) {
statsList = statsList.map((a, i) =>
i === idx
? { ...a, stats: normalizeSnapshot(payload) }
: a
);
} else {
// Nouvel agent : recharger tout
load();
}
}
if (evt.type === "agent.connected" || evt.type === "agent.disconnected") {
load();
}
});
});
onDestroy(() => {
disconnect?.();
if (refreshTimer) clearInterval(refreshTimer);
});
</script>
<svelte:head>
<title>Statistics — Nexarr</title>
</svelte:head>
<div class="min-h-full bg-abyss-900 bg-grid-faint bg-grid text-slate-200">
<!-- Page title bar -->
<div class="border-b border-white/[0.06] bg-abyss-900/80 px-4 md:px-6 py-3 shrink-0">
<div class="max-w-screen-xl mx-auto flex items-center gap-3">
<svg class="w-4 h-4 text-cyan-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span class="text-sm font-medium text-slate-300">Statistics</span>
{#if statsList !== null}
<span class="text-xs text-slate-600">{statsList.length} host{statsList.length !== 1 ? "s" : ""}</span>
{/if}
<div class="ml-auto">
<button
onclick={load}
class="nav-btn"
title="Refresh"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
</div>
</div>
</div>
<main class="p-4 md:p-6 max-w-screen-xl mx-auto">
<!-- Loading state -->
{#if statsList === null}
<div class="flex flex-col items-center justify-center h-64 gap-3 text-slate-600">
<div class="w-8 h-8 border-2 border-cyan-400/30 border-t-cyan-400 rounded-full animate-spin"></div>
<span class="text-sm">Loading…</span>
</div>
<!-- Error state -->
{:else if loadError}
<div class="flex items-center gap-3 max-w-md mx-auto mt-16 p-4 card border-signal-red/20">
<span class="text-signal-red text-xl"></span>
<p class="text-signal-red text-sm">{loadError}</p>
</div>
<!-- Empty state -->
{:else if statsList.length === 0}
<div class="flex flex-col items-center justify-center h-64 gap-2 text-slate-600">
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span class="text-sm">No agents connected</span>
</div>
<!-- Agents list -->
{:else}
{#each statsList.slice().sort((a, b) => (a.alias || a.hostname).localeCompare(b.alias || b.hostname)) as agent (agent.agent_id)}
<section class="mb-8">
<!-- Agent header (collapsible) -->
<button
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
onclick={() => toggleSection(agent.agent_id)}
type="button"
>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{agent._expanded ? 'rotate-0' : '-rotate-90'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
</svg>
<span class="{agent.stats ? 'dot-running' : 'dot-other'}"></span>
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
{agent.alias || agent.hostname}
</h2>
{#if agent.alias}
<span class="hidden sm:inline font-mono text-xs text-slate-600">{agent.hostname}</span>
{/if}
{#if agent.ip_address}
<span class="hidden sm:inline font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full border border-white/[0.06]">
{agent.ip_address}
</span>
{/if}
{#if agent.stats}
<span class="ml-auto text-xs text-slate-600 tabular-nums">
CPU {agent.stats.cpu_pct.toFixed(1)}%
</span>
{/if}
</button>
<!-- Preview band — always visible -->
<div class="mb-3 grid grid-cols-3 gap-2">
{#if agent.stats}
{@const s = agent.stats}
{@const mp = memPct(s)}
<!-- CPU -->
<div class="bg-abyss-800/50 rounded-lg px-3 py-2 flex flex-col gap-1.5">
<span class="text-[10px] font-semibold text-slate-500 uppercase tracking-widest">CPU</span>
<div class="flex items-center gap-2">
<span class="text-sm font-bold tabular-nums {cpuColor(s.cpu_pct)}">{s.cpu_pct.toFixed(1)}%</span>
<div class="flex-1 h-1 rounded-full bg-white/[0.06] overflow-hidden">
<div class="h-full rounded-full transition-all duration-500 {cpuBarColor(s.cpu_pct)}" style="width: {Math.min(100, s.cpu_pct)}%"></div>
</div>
</div>
</div>
<!-- Memory -->
<div class="bg-abyss-800/50 rounded-lg px-3 py-2 flex flex-col gap-1.5">
<span class="text-[10px] font-semibold text-slate-500 uppercase tracking-widest">Memory</span>
<div class="flex items-center gap-2">
<span class="text-sm font-bold tabular-nums {memColor(mp)}">{mp.toFixed(1)}%</span>
<div class="flex-1 h-1 rounded-full bg-white/[0.06] overflow-hidden">
<div class="h-full rounded-full transition-all duration-500 {memBarColor(mp)}" style="width: {mp}%"></div>
</div>
</div>
</div>
<!-- Network -->
<div class="bg-abyss-800/50 rounded-lg px-3 py-2 flex flex-col gap-1.5">
<span class="text-[10px] font-semibold text-slate-500 uppercase tracking-widest">Network</span>
<div class="flex flex-col gap-0.5">
<span class="text-xs tabular-nums"><span class="text-slate-500"></span> <span class="text-emerald-400 font-medium">{formatRate(totalRecvRate(s))}</span></span>
<span class="text-xs tabular-nums"><span class="text-slate-500"></span> <span class="text-cyan-400 font-medium">{formatRate(totalSentRate(s))}</span></span>
</div>
</div>
{:else}
<!-- No snapshot yet -->
{#each [0, 1, 2] as _}
<div class="bg-abyss-800/50 rounded-lg px-3 py-2 flex items-center justify-center">
<span class="text-xs text-slate-600"></span>
</div>
{/each}
{/if}
</div>
{#if agent._expanded}
<div class="bg-abyss-800/40 rounded-xl border border-white/[0.04] p-4 space-y-5">
{#if !agent.stats}
<!-- No snapshot yet -->
<div class="flex items-center gap-2 py-6 justify-center text-slate-600 text-sm">
<div class="w-4 h-4 border-2 border-slate-600/40 border-t-slate-500 rounded-full animate-spin"></div>
Waiting for first snapshot…
</div>
{:else}
{@const s = agent.stats}
<!-- ── Overview row ── -->
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3">
<!-- CPU -->
<div class="card p-4 flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-widest">CPU</span>
<svg class="w-4 h-4 text-cyan-400/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M9 3H7a2 2 0 00-2 2v2M9 3h6M9 3v2m6-2h2a2 2 0 012 2v2m0 0V7m0 0h2M3 9v6m0 0v2a2 2 0 002 2h2m-2-2H3m18-6v6m0 0v2a2 2 0 01-2 2h-2m2-2h2M9 21h6m-6 0v-2m6 2v-2M9 19H7a2 2 0 01-2-2v-2m14 0v2a2 2 0 01-2 2h-2" />
</svg>
</div>
<div class="text-3xl font-bold tabular-nums {cpuColor(s.cpu_pct)}">
{s.cpu_pct.toFixed(1)}<span class="text-lg">%</span>
</div>
<div class="h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500 {cpuBarColor(s.cpu_pct)}"
style="width: {Math.min(100, s.cpu_pct)}%"
></div>
</div>
{#if s.cpu_per_core?.length > 0}
<div class="flex gap-0.5 flex-wrap">
{#each s.cpu_per_core as core, i}
<div class="flex flex-col items-center gap-0.5" title="Core {i}: {core.toFixed(1)}%">
<div class="w-3 rounded-sm overflow-hidden bg-white/[0.04]" style="height: 24px">
<div
class="w-full rounded-sm transition-all duration-500 {cpuBarColor(core)}"
style="height: {Math.min(100, core)}%; margin-top: {100 - Math.min(100, core)}%"
></div>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Memory -->
{#each [memPct(s)] as mp}
<div class="card p-4 flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-widest">Memory</span>
<svg class="w-4 h-4 text-violet-400/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
</div>
<div class="text-3xl font-bold tabular-nums {memColor(mp)}">
{mp.toFixed(1)}<span class="text-lg">%</span>
</div>
<div class="h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500 {memBarColor(mp)}"
style="width: {mp}%"
></div>
</div>
<div class="text-xs text-slate-500 tabular-nums">
{formatBytes(s.mem_used)} / {formatBytes(s.mem_total)}
</div>
</div>
{/each}
<!-- Network summary -->
<div class="card p-4 flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-widest">Network</span>
<svg class="w-4 h-4 text-emerald-400/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
</svg>
</div>
<div class="space-y-1.5">
<div class="flex items-center justify-between text-xs">
<span class="text-slate-500">↓ Recv</span>
<span class="tabular-nums text-emerald-400 font-medium">{formatRate(totalRecvRate(s))}</span>
</div>
<div class="flex items-center justify-between text-xs">
<span class="text-slate-500">↑ Send</span>
<span class="tabular-nums text-cyan-400 font-medium">{formatRate(totalSentRate(s))}</span>
</div>
</div>
<div class="text-xs text-slate-600">
{s.net_interfaces.length} interface{s.net_interfaces.length !== 1 ? "s" : ""}
</div>
</div>
<!-- Processes summary -->
<div class="card p-4 flex flex-col gap-3">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold text-slate-500 uppercase tracking-widest">Processes</span>
<svg class="w-4 h-4 text-amber-400/60" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</div>
<div class="text-3xl font-bold tabular-nums text-amber-400">
{s.processes.length}
</div>
<div class="text-xs text-slate-500">Running processes</div>
{#if s.processes.length > 0}
{@const top = s.processes.slice().sort((a, b) => b.cpu_pct - a.cpu_pct)[0]}
<div class="text-xs text-slate-600 truncate" title="Top CPU: {top.name}">
Top: <span class="font-mono text-slate-400">{top.name}</span>
<span class="text-amber-400/80 ml-1">{top.cpu_pct.toFixed(1)}%</span>
</div>
{/if}
</div>
</div>
<!-- ── Network interfaces ── -->
{#if s.net_interfaces.length > 0}
<div>
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-widest mb-2 px-1">Network Interfaces</h3>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm min-w-[520px]">
<thead>
<tr class="border-b border-white/[0.06] text-xs uppercase tracking-wider text-slate-600">
<th class="px-4 py-3 text-left font-medium">Interface</th>
<th class="px-4 py-3 text-right font-medium">↓ Recv rate</th>
<th class="px-4 py-3 text-right font-medium">↑ Send rate</th>
<th class="px-4 py-3 text-right font-medium">Total recv</th>
<th class="px-4 py-3 text-right font-medium">Total sent</th>
</tr>
</thead>
<tbody>
{#each s.net_interfaces as iface (iface.name)}
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors">
<td class="px-4 py-3 font-mono text-xs text-slate-300">{iface.name}</td>
<td class="px-4 py-3 text-right text-xs tabular-nums text-emerald-400">{formatRate(iface.bytes_recv_rate)}</td>
<td class="px-4 py-3 text-right text-xs tabular-nums text-cyan-400">{formatRate(iface.bytes_sent_rate)}</td>
<td class="px-4 py-3 text-right text-xs tabular-nums text-slate-400">{formatBytes(iface.bytes_recv)}</td>
<td class="px-4 py-3 text-right text-xs tabular-nums text-slate-400">{formatBytes(iface.bytes_sent)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
{/if}
<!-- ── Disks ── -->
{#if s.disks.length > 0}
<div>
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-widest mb-2 px-1">Disks</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3">
{#each s.disks as disk (disk.path)}
{@const diskPct = disk.total > 0 ? Math.min(100, (disk.used / disk.total) * 100) : 0}
<div class="card p-4 flex flex-col gap-2">
<div class="flex items-center justify-between gap-2">
<span class="font-mono text-xs text-slate-300 truncate" title={disk.path}>{disk.path}</span>
<span class="text-xs tabular-nums text-slate-500 shrink-0">{diskPct.toFixed(0)}%</span>
</div>
<div class="h-1.5 rounded-full bg-white/[0.06] overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500 {diskBarColor(diskPct)}"
style="width: {diskPct}%"
></div>
</div>
<div class="flex items-center justify-between text-xs text-slate-600 tabular-nums">
<span>{formatBytes(disk.used)} used</span>
<span>{formatBytes(disk.total)}</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- ── Top processes ── -->
{#if s.processes.length > 0}
<div>
<h3 class="text-xs font-semibold text-slate-500 uppercase tracking-widest mb-2 px-1">
Top Processes (by CPU)
</h3>
<div class="card overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm min-w-[520px]">
<thead>
<tr class="border-b border-white/[0.06] text-xs uppercase tracking-wider text-slate-600">
<th class="px-4 py-3 text-right font-medium w-16">PID</th>
<th class="px-4 py-3 text-left font-medium w-36">Name</th>
<th class="px-4 py-3 text-left font-medium">CMD</th>
<th class="px-4 py-3 text-right font-medium w-20">CPU%</th>
<th class="px-4 py-3 text-right font-medium w-24">RAM</th>
</tr>
</thead>
<tbody>
{#each topProcesses(s) as proc (proc.pid)}
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors">
<td class="px-4 py-2.5 text-right font-mono text-xs text-slate-600 tabular-nums">{proc.pid}</td>
<td class="px-4 py-2.5 font-mono text-xs text-slate-300 max-w-[144px] truncate" title={proc.name}>{proc.name}</td>
<td class="px-4 py-2.5 font-mono text-xs text-slate-600 max-w-[320px] truncate" title={proc.cmd}>{proc.cmd}</td>
<td class="px-4 py-2.5 text-right text-xs tabular-nums {cpuColor(proc.cpu_pct)} font-medium">
{proc.cpu_pct.toFixed(1)}%
</td>
<td class="px-4 py-2.5 text-right text-xs tabular-nums text-slate-400">{formatBytes(proc.mem_rss)}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
</div>
{/if}
{/if}
</div>
{/if}
</section>
{/each}
{/if}
</main>
</div>