feat: add proxy and statistics features
This commit is contained in:
22
Makefile
22
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
|
||||
|
||||
84
agent/Cargo.lock
generated
84
agent/Cargo.lock
generated
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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
242
agent/src/stats.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
14
docker-compose.agent.local.yml
Normal file
14
docker-compose.agent.local.yml
Normal 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
45
docker-compose.local.yml
Normal 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:
|
||||
23
docker-compose.server.local.yml
Normal file
23
docker-compose.server.local.yml
Normal 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:
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
@ -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
173
web/src/lib/Sidebar.svelte
Normal 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>
|
||||
@ -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 {
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
|
||||
@ -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 ─────────────────────────────────────────────────────── -->
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
88
web/src/routes/proxy/+page.svelte
Normal file
88
web/src/routes/proxy/+page.svelte
Normal 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>
|
||||
547
web/src/routes/stats/+page.svelte
Normal file
547
web/src/routes/stats/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user