diff --git a/agent/src/docker.rs b/agent/src/docker.rs index 27d19fa..39f6884 100644 --- a/agent/src/docker.rs +++ b/agent/src/docker.rs @@ -4,12 +4,15 @@ use bollard::{ ListContainersOptions, LogOutput, LogsOptions, RemoveContainerOptions, StartContainerOptions, StopContainerOptions, }, + image::ListImagesOptions, + network::ListNetworksOptions, + volume::ListVolumesOptions, Docker, }; use futures_util::Stream; use std::{collections::HashMap, pin::Pin}; -use crate::proto::{ContainerInfo, ContainerPort}; +use crate::proto::{ContainerInfo, ContainerPort, ImageInfo, NetworkInfo, VolumeInfo}; // ── Public trait ───────────────────────────────────────────────────────────── @@ -24,6 +27,10 @@ pub trait ContainerBackend: Clone + Send + Sync + 'static { fn restart(&self, id: &str) -> impl std::future::Future> + Send; fn remove(&self, id: &str) -> impl std::future::Future> + Send; + fn list_images(&self) -> impl std::future::Future>> + Send; + fn list_volumes(&self) -> impl std::future::Future>> + Send; + fn list_networks(&self) -> impl std::future::Future>> + Send; + fn logs( &self, id: &str, @@ -138,6 +145,55 @@ impl ContainerBackend for DockerClient { Ok(()) } + async fn list_images(&self) -> Result> { + let images = self + .inner + .list_images(None::>) + .await?; + Ok(images + .into_iter() + .map(|c| ImageInfo { + id: c.id, + tags: c.repo_tags, + size: c.size, + created_at: c.created, + }) + .collect()) + } + + async fn list_volumes(&self) -> Result> { + let info = self + .inner + .list_volumes(None::>) + .await?; + Ok(info + .volumes + .unwrap_or_default() + .into_iter() + .map(|v| VolumeInfo { + name: v.name, + driver: v.driver, + mountpoint: v.mountpoint, + }) + .collect()) + } + + async fn list_networks(&self) -> Result> { + let networks = self + .inner + .list_networks(None::>) + .await?; + Ok(networks + .into_iter() + .map(|n| NetworkInfo { + id: n.id.unwrap_or_default(), + name: n.name.unwrap_or_default(), + driver: n.driver.unwrap_or_default(), + scope: n.scope.unwrap_or_default(), + }) + .collect()) + } + fn logs( &self, id: &str, @@ -230,6 +286,38 @@ pub mod tests { }]) } + async fn list_images(&self) -> Result> { + self.record("list_images".to_string()); + self.maybe_err()?; + Ok(vec![ImageInfo { + id: "sha256:abc".to_string(), + tags: vec!["nginx:latest".to_string()], + size: 1024, + created_at: 1_700_000_000, + }]) + } + + async fn list_volumes(&self) -> Result> { + self.record("list_volumes".to_string()); + self.maybe_err()?; + Ok(vec![VolumeInfo { + name: "data".to_string(), + driver: "local".to_string(), + mountpoint: "/var/lib/docker/volumes/data/_data".to_string(), + }]) + } + + async fn list_networks(&self) -> Result> { + self.record("list_networks".to_string()); + self.maybe_err()?; + Ok(vec![NetworkInfo { + id: "net123".to_string(), + name: "bridge".to_string(), + driver: "bridge".to_string(), + scope: "local".to_string(), + }]) + } + async fn start(&self, id: &str) -> Result<()> { self.record(format!("start:{id}")); self.maybe_err() @@ -291,6 +379,81 @@ pub mod tests { assert!(err.to_string().contains("docker down")); } + // ── list_images ─────────────────────────────────────────────────────────── + + #[tokio::test] + async fn mock_list_images_returns_one_entry() { + let backend = MockBackend::new(); + let images = backend.list_images().await.unwrap(); + assert_eq!(images.len(), 1); + assert_eq!(images[0].tags, vec!["nginx:latest"]); + } + + #[tokio::test] + async fn mock_list_images_records_call() { + let backend = MockBackend::new(); + backend.list_images().await.unwrap(); + let calls = backend.calls.lock().unwrap().clone(); + assert_eq!(calls, vec!["list_images"]); + } + + #[tokio::test] + async fn mock_list_images_propagates_error() { + let backend = MockBackend::failing("image daemon down"); + let err = backend.list_images().await.unwrap_err(); + assert!(err.to_string().contains("image daemon down")); + } + + // ── list_volumes ────────────────────────────────────────────────────────── + + #[tokio::test] + async fn mock_list_volumes_returns_one_entry() { + let backend = MockBackend::new(); + let volumes = backend.list_volumes().await.unwrap(); + assert_eq!(volumes.len(), 1); + assert_eq!(volumes[0].name, "data"); + } + + #[tokio::test] + async fn mock_list_volumes_records_call() { + let backend = MockBackend::new(); + backend.list_volumes().await.unwrap(); + let calls = backend.calls.lock().unwrap().clone(); + assert_eq!(calls, vec!["list_volumes"]); + } + + #[tokio::test] + async fn mock_list_volumes_propagates_error() { + let backend = MockBackend::failing("volume daemon down"); + let err = backend.list_volumes().await.unwrap_err(); + assert!(err.to_string().contains("volume daemon down")); + } + + // ── list_networks ───────────────────────────────────────────────────────── + + #[tokio::test] + async fn mock_list_networks_returns_one_entry() { + let backend = MockBackend::new(); + let networks = backend.list_networks().await.unwrap(); + assert_eq!(networks.len(), 1); + assert_eq!(networks[0].name, "bridge"); + } + + #[tokio::test] + async fn mock_list_networks_records_call() { + let backend = MockBackend::new(); + backend.list_networks().await.unwrap(); + let calls = backend.calls.lock().unwrap().clone(); + assert_eq!(calls, vec!["list_networks"]); + } + + #[tokio::test] + async fn mock_list_networks_propagates_error() { + let backend = MockBackend::failing("network daemon down"); + let err = backend.list_networks().await.unwrap_err(); + assert!(err.to_string().contains("network daemon down")); + } + // ── start / stop / restart / remove ────────────────────────────────────── #[tokio::test] diff --git a/agent/src/main.rs b/agent/src/main.rs index 575abea..5f38127 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -12,6 +12,7 @@ use proto::{ agent_gateway_client::AgentGatewayClient, agent_message, server_message, AgentHandshake, AgentMessage, ContainerAction, ContainerSnapshot, + ImageInfo, VolumeInfo, NetworkInfo, }; use std::{collections::HashMap, env, time::Duration}; use tokio::{sync::mpsc, task::JoinHandle, time}; @@ -73,20 +74,45 @@ async fn run(url: &str, token: &str, hostname: &str, docker: DockerClient) -> Re loop { tokio::select! { _ = snapshot_ticker.tick() => { - match time::timeout(Duration::from_secs(5), docker.list_containers()).await { - Err(_) => warn!("docker list timed out"), - Ok(Err(e)) => warn!("docker list failed: {:#}", e), - Ok(Ok(containers)) => { - let msg = AgentMessage { - payload: Some(agent_message::Payload::Snapshot(ContainerSnapshot { - containers, - timestamp: unix_now(), - })), - }; - if tx.send(msg).await.is_err() { - break; - } - } + let (containers_res, images_res, volumes_res, networks_res) = tokio::join!( + time::timeout(Duration::from_secs(5), docker.list_containers()), + time::timeout(Duration::from_secs(5), docker.list_images()), + time::timeout(Duration::from_secs(5), docker.list_volumes()), + time::timeout(Duration::from_secs(5), docker.list_networks()), + ); + + let containers = match containers_res { + Err(_) => { warn!("docker list_containers timed out"); continue; } + Ok(Err(e)) => { warn!("docker list_containers failed: {:#}", e); continue; } + Ok(Ok(v)) => v, + }; + let images: Vec = match images_res { + Err(_) => { warn!("docker list_images timed out"); vec![] } + Ok(Err(e)) => { warn!("docker list_images failed: {:#}", e); vec![] } + Ok(Ok(v)) => v, + }; + let volumes: Vec = match volumes_res { + Err(_) => { warn!("docker list_volumes timed out"); vec![] } + Ok(Err(e)) => { warn!("docker list_volumes failed: {:#}", e); vec![] } + Ok(Ok(v)) => v, + }; + let networks: Vec = match networks_res { + Err(_) => { warn!("docker list_networks timed out"); vec![] } + Ok(Err(e)) => { warn!("docker list_networks failed: {:#}", e); vec![] } + Ok(Ok(v)) => v, + }; + + let msg = AgentMessage { + payload: Some(agent_message::Payload::Snapshot(ContainerSnapshot { + containers, + timestamp: unix_now(), + images, + volumes, + networks, + })), + }; + if tx.send(msg).await.is_err() { + break; } } diff --git a/proto/agent/v1/agent.proto b/proto/agent/v1/agent.proto index 6fb7867..d3d2381 100644 --- a/proto/agent/v1/agent.proto +++ b/proto/agent/v1/agent.proto @@ -34,9 +34,32 @@ message AgentHandshake { string os = 4; } +message ImageInfo { + string id = 1; + repeated string tags = 2; + int64 size = 3; + int64 created_at = 4; +} + +message VolumeInfo { + string name = 1; + string driver = 2; + string mountpoint = 3; +} + +message NetworkInfo { + string id = 1; + string name = 2; + string driver = 3; + string scope = 4; +} + message ContainerSnapshot { repeated ContainerInfo containers = 1; - int64 timestamp = 2; + int64 timestamp = 2; + repeated ImageInfo images = 3; + repeated VolumeInfo volumes = 4; + repeated NetworkInfo networks = 5; } message CommandResult { diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go index f884afe..86ed28e 100644 --- a/server/internal/api/api_test.go +++ b/server/internal/api/api_test.go @@ -559,6 +559,188 @@ func TestDeleteAgent_NonExistent(t *testing.T) { } } +// ── ListImages ──────────────────────────────────────────────────────────────── + +func TestListImages_Empty(t *testing.T) { + h, _, _, _ := newTestHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/images", nil) + w := httptest.NewRecorder() + h.ListImages(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var out []map[string]any + json.NewDecoder(w.Body).Decode(&out) + if len(out) != 0 { + t.Errorf("expected empty list, got %d", len(out)) + } +} + +func TestListImages_WithData(t *testing.T) { + h, _, reg, _ := newTestHandler(t) + + reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux") + reg.UpdateResources("a1", + nil, + []*agentv1.ImageInfo{ + {Id: "sha256:abc", Tags: []string{"nginx:latest"}, Size: 50000000, CreatedAt: 1700000000}, + {Id: "sha256:def", Tags: []string{"redis:7"}, Size: 30000000, CreatedAt: 1700000001}, + }, + nil, + nil, + ) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/images", nil) + w := httptest.NewRecorder() + h.ListImages(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var out []struct { + AgentID string `json:"agent_id"` + Hostname string `json:"hostname"` + ID string `json:"id"` + Tags []string `json:"tags"` + Size int64 `json:"size"` + CreatedAt int64 `json:"created_at"` + } + json.NewDecoder(w.Body).Decode(&out) + + if len(out) != 2 { + t.Fatalf("expected 2 images, got %d", len(out)) + } + if out[0].AgentID != "a1" { + t.Errorf("expected agent_id 'a1', got %q", out[0].AgentID) + } + if out[0].ID != "sha256:abc" && out[1].ID != "sha256:abc" { + t.Error("expected sha256:abc in results") + } +} + +// ── ListVolumes ─────────────────────────────────────────────────────────────── + +func TestListVolumes_Empty(t *testing.T) { + h, _, _, _ := newTestHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/volumes", nil) + w := httptest.NewRecorder() + h.ListVolumes(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var out []map[string]any + json.NewDecoder(w.Body).Decode(&out) + if len(out) != 0 { + t.Errorf("expected empty list, got %d", len(out)) + } +} + +func TestListVolumes_WithData(t *testing.T) { + h, _, reg, _ := newTestHandler(t) + + reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux") + reg.UpdateResources("a1", + nil, + nil, + []*agentv1.VolumeInfo{ + {Name: "data", Driver: "local", Mountpoint: "/var/lib/docker/volumes/data/_data"}, + {Name: "cache", Driver: "local", Mountpoint: "/var/lib/docker/volumes/cache/_data"}, + }, + nil, + ) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/volumes", nil) + w := httptest.NewRecorder() + h.ListVolumes(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var out []struct { + AgentID string `json:"agent_id"` + Name string `json:"name"` + Driver string `json:"driver"` + Mountpoint string `json:"mountpoint"` + } + json.NewDecoder(w.Body).Decode(&out) + + if len(out) != 2 { + t.Fatalf("expected 2 volumes, got %d", len(out)) + } + names := map[string]bool{out[0].Name: true, out[1].Name: true} + if !names["data"] || !names["cache"] { + t.Errorf("expected volumes 'data' and 'cache', got %v", names) + } +} + +// ── ListNetworks ────────────────────────────────────────────────────────────── + +func TestListNetworks_Empty(t *testing.T) { + h, _, _, _ := newTestHandler(t) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/networks", nil) + w := httptest.NewRecorder() + h.ListNetworks(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var out []map[string]any + json.NewDecoder(w.Body).Decode(&out) + if len(out) != 0 { + t.Errorf("expected empty list, got %d", len(out)) + } +} + +func TestListNetworks_WithData(t *testing.T) { + h, _, reg, _ := newTestHandler(t) + + reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux") + reg.UpdateResources("a1", + nil, + nil, + nil, + []*agentv1.NetworkInfo{ + {Id: "net1", Name: "bridge", Driver: "bridge", Scope: "local"}, + {Id: "net2", Name: "host", Driver: "host", Scope: "local"}, + }, + ) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/networks", nil) + w := httptest.NewRecorder() + h.ListNetworks(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var out []struct { + AgentID string `json:"agent_id"` + ID string `json:"id"` + Name string `json:"name"` + Driver string `json:"driver"` + Scope string `json:"scope"` + } + json.NewDecoder(w.Body).Decode(&out) + + if len(out) != 2 { + t.Fatalf("expected 2 networks, got %d", len(out)) + } + ids := map[string]bool{out[0].ID: true, out[1].ID: true} + if !ids["net1"] || !ids["net2"] { + t.Errorf("expected net1 and net2, got %v", ids) + } +} + // ── ContainerAction ─────────────────────────────────────────────────────────── func TestContainerAction_AgentNotConnected(t *testing.T) { diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 498bad3..bd206ce 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -178,6 +178,91 @@ func (h *Handler) ContainerAction(w http.ResponseWriter, r *http.Request) { jsonOK(w, map[string]string{"command_id": cmdID}) } +// ── Images ──────────────────────────────────────────────────────────────────── + +func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) { + type imageDTO struct { + AgentID string `json:"agent_id"` + Hostname string `json:"hostname"` + Alias string `json:"alias"` + ID string `json:"id"` + Tags []string `json:"tags"` + Size int64 `json:"size"` + CreatedAt int64 `json:"created_at"` + } + var out []imageDTO + for _, agent := range h.registry.List() { + for _, img := range agent.Images { + out = append(out, imageDTO{ + AgentID: agent.ID, + Hostname: agent.Hostname, + Alias: agent.Alias, + ID: img.GetId(), + Tags: img.GetTags(), + Size: img.GetSize(), + CreatedAt: img.GetCreatedAt(), + }) + } + } + jsonOK(w, out) +} + +// ── Volumes ─────────────────────────────────────────────────────────────────── + +func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) { + type volumeDTO struct { + AgentID string `json:"agent_id"` + Hostname string `json:"hostname"` + Alias string `json:"alias"` + Name string `json:"name"` + Driver string `json:"driver"` + Mountpoint string `json:"mountpoint"` + } + var out []volumeDTO + for _, agent := range h.registry.List() { + for _, vol := range agent.Volumes { + out = append(out, volumeDTO{ + AgentID: agent.ID, + Hostname: agent.Hostname, + Alias: agent.Alias, + Name: vol.GetName(), + Driver: vol.GetDriver(), + Mountpoint: vol.GetMountpoint(), + }) + } + } + jsonOK(w, out) +} + +// ── Networks ────────────────────────────────────────────────────────────────── + +func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) { + type networkDTO struct { + AgentID string `json:"agent_id"` + Hostname string `json:"hostname"` + Alias string `json:"alias"` + ID string `json:"id"` + Name string `json:"name"` + Driver string `json:"driver"` + Scope string `json:"scope"` + } + var out []networkDTO + for _, agent := range h.registry.List() { + for _, net := range agent.Networks { + out = append(out, networkDTO{ + AgentID: agent.ID, + Hostname: agent.Hostname, + Alias: agent.Alias, + ID: net.GetId(), + Name: net.GetName(), + Driver: net.GetDriver(), + Scope: net.GetScope(), + }) + } + } + jsonOK(w, out) +} + // ── Agent token provisioning ────────────────────────────────────────────────── func (h *Handler) CreateAgentToken(w http.ResponseWriter, r *http.Request) { diff --git a/server/internal/api/router.go b/server/internal/api/router.go index 7f58f15..ac8ed03 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -46,6 +46,9 @@ func NewRouter(h *Handler) http.Handler { r.Patch("/agents/{agentID}", h.UpdateAgent) r.Delete("/agents/{agentID}", h.DeleteAgent) r.Get("/containers", h.ListContainers) + r.Get("/images", h.ListImages) + r.Get("/volumes", h.ListVolumes) + r.Get("/networks", h.ListNetworks) r.Post("/agents/{agentID}/containers/{containerID}/action", h.ContainerAction) r.Get("/agents/{agentID}/containers/{containerID}/logs", h.LogsWS) r.Get("/events", h.EventsWS) diff --git a/server/internal/grpc/gateway.go b/server/internal/grpc/gateway.go index ec9c63f..246da70 100644 --- a/server/internal/grpc/gateway.go +++ b/server/internal/grpc/gateway.go @@ -108,12 +108,21 @@ func (g *Gateway) Tunnel(stream agentv1.AgentGateway_TunnelServer) error { switch p := msg.Payload.(type) { case *agentv1.AgentMessage_Snapshot: - g.registry.UpdateContainers(agentID, p.Snapshot.Containers) + g.registry.UpdateResources(agentID, p.Snapshot.Containers, p.Snapshot.Images, p.Snapshot.Volumes, p.Snapshot.Networks) g.broker.Publish(broker.Event{ Type: "containers.updated", AgentID: agentID, Payload: p.Snapshot.Containers, }) + g.broker.Publish(broker.Event{ + Type: "resources.updated", + AgentID: agentID, + Payload: map[string]any{ + "images": p.Snapshot.Images, + "volumes": p.Snapshot.Volumes, + "networks": p.Snapshot.Networks, + }, + }) case *agentv1.AgentMessage_Result: g.broker.Publish(broker.Event{ diff --git a/server/internal/grpc/registry.go b/server/internal/grpc/registry.go index 9811421..f7a1417 100644 --- a/server/internal/grpc/registry.go +++ b/server/internal/grpc/registry.go @@ -16,6 +16,9 @@ type AgentState struct { OS string LastSeenAt time.Time Containers []*agentv1.ContainerInfo + Images []*agentv1.ImageInfo + Volumes []*agentv1.VolumeInfo + Networks []*agentv1.NetworkInfo cmdCh chan *agentv1.ServerMessage } @@ -80,6 +83,18 @@ func (r *Registry) UpdateContainers(id string, containers []*agentv1.ContainerIn } } +func (r *Registry) UpdateResources(id string, containers []*agentv1.ContainerInfo, images []*agentv1.ImageInfo, volumes []*agentv1.VolumeInfo, networks []*agentv1.NetworkInfo) { + r.mu.Lock() + defer r.mu.Unlock() + if s, ok := r.agents[id]; ok { + s.Containers = containers + s.Images = images + s.Volumes = volumes + s.Networks = networks + s.LastSeenAt = time.Now() + } +} + // UpdateAlias refreshes the alias for a live agent (called after an admin update). func (r *Registry) UpdateAlias(id, alias string) { r.mu.Lock() diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 6f98c30..4ab11ef 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -118,6 +118,71 @@ export async function containerAction( return r.json(); } +export interface ImageEntry { + agent_id: string; + hostname: string; + alias: string; + id: string; + tags: string[]; + size: number; + created_at: number; +} + +export interface VolumeEntry { + agent_id: string; + hostname: string; + alias: string; + name: string; + driver: string; + mountpoint: string; +} + +export interface NetworkEntry { + agent_id: string; + hostname: string; + alias: string; + id: string; + name: string; + driver: string; + scope: string; +} + +export async function fetchImages(): Promise { + const ac = new AbortController(); + const t = setTimeout(() => ac.abort(), 8000); + try { + const r = await apiFetch(`${BASE}/images`, { signal: ac.signal }); + if (!r.ok) throw new Error(`images: ${r.status}`); + return r.json(); + } finally { + clearTimeout(t); + } +} + +export async function fetchVolumes(): Promise { + const ac = new AbortController(); + const t = setTimeout(() => ac.abort(), 8000); + try { + const r = await apiFetch(`${BASE}/volumes`, { signal: ac.signal }); + if (!r.ok) throw new Error(`volumes: ${r.status}`); + return r.json(); + } finally { + clearTimeout(t); + } +} + +export async function fetchNetworks(): Promise { + const ac = new AbortController(); + const t = setTimeout(() => ac.abort(), 8000); + try { + const r = await apiFetch(`${BASE}/networks`, { signal: ac.signal }); + if (!r.ok) throw new Error(`networks: ${r.status}`); + return r.json(); + } finally { + clearTimeout(t); + } +} + export function connectLogs( agentId: string, containerId: string, diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 2c07cef..a7a2d29 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -8,14 +8,21 @@ import { goto } from "$app/navigation"; import { fetchContainers, + fetchImages, + fetchVolumes, + fetchNetworks, containerAction, connectEvents, type ContainerEntry, type ContainerPort, + type ImageEntry, + type VolumeEntry, + type NetworkEntry, } from "$lib/api"; import { clearToken } from "$lib/auth"; import LogModal from "$lib/LogModal.svelte"; + // ── Logs modal ──────────────────────────────────────────────────────────── let logTarget = $state<{ agentId: string; containerId: string; name: string } | null>(null); function openLogs(agentId: string, containerId: string, name: string) { @@ -27,11 +34,26 @@ goto("/login"); } - let entries = $state(null); - let loadError = $state(null); - let actionPending = $state(null); - let toast = $state<{ msg: string; ok: boolean } | null>(null); + // ── Tab state ───────────────────────────────────────────────────────────── + type Tab = "containers" | "images" | "volumes" | "networks"; + let activeTab = $state("containers"); + // ── Data ────────────────────────────────────────────────────────────────── + let entries = $state(null); + let images = $state(null); + let volumes = $state(null); + let networks = $state(null); + let loadError = $state(null); + let actionPending = $state(null); + let toast = $state<{ msg: string; ok: boolean } | null>(null); + + // ── Collapsed states (independent per view) ─────────────────────────────── + let collapsed = $state>({}); + let collapsedImages = $state>({}); + let collapsedVolumes = $state>({}); + let collapsedNetworks= $state>({}); + + // ── Derived: containers grouped by agent ───────────────────────────────── const byAgent = $derived( (entries ?? []).reduce>((acc, e) => { (acc[e.agent_id] ??= []).push(e); @@ -39,7 +61,6 @@ }, {}) ); - // Tri alphabétique des agents : alias (si défini) sinon hostname, insensible à la casse const sortedAgents = $derived( Object.entries(byAgent).sort(([, a], [, b]) => { const labelA = (a[0]?.alias || a[0]?.hostname || "").toLowerCase(); @@ -48,23 +69,55 @@ }) ); - // État replié/déplié par agent (déplié par défaut) - let collapsed = $state>({}); + // ── Derived: images grouped by agent ───────────────────────────────────── + const byAgentImages = $derived( + (images ?? []).reduce>((acc, e) => { + (acc[e.agent_id] ??= []).push(e); + return acc; + }, {}) + ); - function toggleSection(agentId: string) { - collapsed[agentId] = !collapsed[agentId]; - } + const sortedAgentImages = $derived( + Object.entries(byAgentImages).sort(([, a], [, b]) => { + const labelA = (a[0]?.alias || a[0]?.hostname || "").toLowerCase(); + const labelB = (b[0]?.alias || b[0]?.hostname || "").toLowerCase(); + return labelA.localeCompare(labelB); + }) + ); - // Pastille de statut pour un agent selon l'état de ses containers - function agentDotClass(containers: ContainerEntry[]): string { - if (containers.length === 0) return "dot-offline"; - const running = containers.filter(c => c.container.state === "running").length; - if (running === containers.length) return "dot-running"; - if (running === 0) return "dot-exited"; - return "dot-other"; - } + // ── Derived: volumes grouped by agent ──────────────────────────────────── + const byAgentVolumes = $derived( + (volumes ?? []).reduce>((acc, e) => { + (acc[e.agent_id] ??= []).push(e); + return acc; + }, {}) + ); - // PWA install prompt + const sortedAgentVolumes = $derived( + Object.entries(byAgentVolumes).sort(([, a], [, b]) => { + const labelA = (a[0]?.alias || a[0]?.hostname || "").toLowerCase(); + const labelB = (b[0]?.alias || b[0]?.hostname || "").toLowerCase(); + return labelA.localeCompare(labelB); + }) + ); + + // ── Derived: networks grouped by agent ─────────────────────────────────── + const byAgentNetworks = $derived( + (networks ?? []).reduce>((acc, e) => { + (acc[e.agent_id] ??= []).push(e); + return acc; + }, {}) + ); + + const sortedAgentNetworks = $derived( + Object.entries(byAgentNetworks).sort(([, a], [, b]) => { + const labelA = (a[0]?.alias || a[0]?.hostname || "").toLowerCase(); + const labelB = (b[0]?.alias || b[0]?.hostname || "").toLowerCase(); + return labelA.localeCompare(labelB); + }) + ); + + // ── PWA install prompt ──────────────────────────────────────────────────── let installPrompt = $state(null); function onBeforeInstallPrompt(e: Event) { @@ -83,18 +136,61 @@ installPrompt = null; } + // ── WebSocket ───────────────────────────────────────────────────────────── let disconnect: (() => void) | null = null; + // ── Load functions ──────────────────────────────────────────────────────── async function load() { loadError = null; try { entries = await fetchContainers() ?? []; - } catch (e: any) { - loadError = e.message; + } catch (e: unknown) { + loadError = e instanceof Error ? e.message : String(e); entries = []; } } + async function loadImages() { + try { + images = await fetchImages() ?? []; + } catch (e: unknown) { + loadError = e instanceof Error ? e.message : String(e); + images = []; + } + } + + async function loadVolumes() { + try { + volumes = await fetchVolumes() ?? []; + } catch (e: unknown) { + loadError = e instanceof Error ? e.message : String(e); + volumes = []; + } + } + + async function loadNetworks() { + try { + networks = await fetchNetworks() ?? []; + } catch (e: unknown) { + loadError = e instanceof Error ? e.message : String(e); + networks = []; + } + } + + async function loadActiveTab() { + if (activeTab === "containers") await load(); + else if (activeTab === "images") await loadImages(); + else if (activeTab === "volumes") await loadVolumes(); + else if (activeTab === "networks") await loadNetworks(); + } + + async function switchTab(tab: Tab) { + activeTab = tab; + loadError = null; + await loadActiveTab(); + } + + // ── Container actions ───────────────────────────────────────────────────── async function doAction( agentId: string, containerId: string, @@ -105,8 +201,8 @@ await containerAction(agentId, containerId, action); showToast(`${action} envoyé`, true); setTimeout(load, 1500); - } catch (e: any) { - showToast(e.message, false); + } catch (e: unknown) { + showToast(e instanceof Error ? e.message : String(e), false); } finally { actionPending = null; } @@ -117,13 +213,25 @@ setTimeout(() => (toast = null), 3000); } + // ── Toggle helpers ──────────────────────────────────────────────────────── + function toggleSection(agentId: string) { collapsed[agentId] = !(collapsed[agentId] ?? true); } + function toggleImages(agentId: string) { collapsedImages[agentId] = !(collapsedImages[agentId] ?? true); } + function toggleVolumes(agentId: string) { collapsedVolumes[agentId] = !(collapsedVolumes[agentId] ?? true); } + function toggleNetworks(agentId: string) { collapsedNetworks[agentId] = !(collapsedNetworks[agentId] ?? true); } + + // ── Lifecycle ───────────────────────────────────────────────────────────── onMount(() => { load(); disconnect = connectEvents((evt) => { - if (evt.type === "containers.updated") load(); - if (evt.type === "agent.connected" || evt.type === "agent.disconnected") load(); + if (evt.type === "containers.updated" || evt.type === "agent.connected" || evt.type === "agent.disconnected") { + if (activeTab === "containers") load(); + } + if (evt.type === "resources.updated") { + if (activeTab === "images") loadImages(); + if (activeTab === "volumes") loadVolumes(); + if (activeTab === "networks") loadNetworks(); + } }); - // Récupère le prompt capturé tôt dans app.html avant que onMount soit prêt if ((window as any).__installPrompt) { installPrompt = (window as any).__installPrompt; (window as any).__installPrompt = null; @@ -138,6 +246,7 @@ window.removeEventListener("appinstalled", onAppInstalled); }); + // ── Helpers ─────────────────────────────────────────────────────────────── function uniquePorts(ports: ContainerPort[] | null) { const seen = new Set(); return (ports ?? []).filter(p => { @@ -151,15 +260,39 @@ function stateDotClass(state: string) { if (state === "running") return "dot-running"; - if (state === "exited") return "dot-exited"; + if (state === "exited") return "dot-exited"; return "dot-other"; } function stateTextClass(state: string) { if (state === "running") return "text-signal-green"; - if (state === "exited") return "text-signal-red"; + if (state === "exited") return "text-signal-red"; return "text-signal-yellow"; } + + function agentDotClass(containers: ContainerEntry[]): string { + if (containers.length === 0) return "dot-offline"; + const running = containers.filter(c => c.container.state === "running").length; + if (running === containers.length) return "dot-running"; + if (running === 0) return "dot-exited"; + return "dot-other"; + } + + function formatSize(bytes: number): string { + if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`; + if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`; + return `${(bytes / 1024).toFixed(0)} KB`; + } + + function formatDate(ts: number): string { + return new Date(ts * 1000).toLocaleDateString("fr-FR"); + } + + function shortId(id: string): string { + // sha256:abc... → take 12 chars after the colon (or from start) + const bare = id.startsWith("sha256:") ? id.slice(7) : id; + return bare.slice(0, 12); + } @@ -197,10 +330,22 @@
- {#if entries !== null} + {#if activeTab === "containers" && entries !== null} {entries.length} containers · {Object.keys(byAgent).length} hosts + {:else if activeTab === "images" && images !== null} + + {images.length} images · {Object.keys(byAgentImages).length} hosts + + {:else if activeTab === "volumes" && volumes !== null} + + {volumes.length} volumes · {Object.keys(byAgentVolumes).length} hosts + + {:else if activeTab === "networks" && networks !== null} + + {networks.length} networks · {Object.keys(byAgentNetworks).length} hosts + {/if} {#if installPrompt} @@ -220,7 +365,7 @@ -
+ +
+ +
+
- {#if entries === null} -
-
- Chargement… -
+ {#key activeTab} + + {#if activeTab === "containers"} + {#if entries === null} +
+
+ Chargement… +
- {:else if loadError} -
- -

{loadError}

-
+ {:else if loadError} +
+ +

{loadError}

+
- {:else if Object.keys(byAgent).length === 0} -
- - - - Aucun agent connecté -
+ {:else if Object.keys(byAgent).length === 0} +
+ + + + Aucun agent connecté +
- {:else} - {#each sortedAgents as [agentId, containers]} - {#if containers.length > 0} - {@const first = containers[0]} - {@const isCollapsed = collapsed[agentId] ?? false} -
+ {:else} + {#each sortedAgents as [agentId, containers]} + {#if containers.length > 0} + {@const first = containers[0]} + {@const isCollapsed = collapsed[agentId] ?? true} +
- - + + + + +

+ {first.alias || first.hostname} +

+ {#if first.alias} + {first.hostname} + {/if} + {#if first.ip_address} + {first.ip_address} + {/if} + + {containers.length} container{containers.length !== 1 ? "s" : ""} + + - {#if !isCollapsed} - - + + + {#each containers as { agent_id, container } (container.id)} + + +
+ + {container.name} +
+ + {container.image} + + {container.state} + + +
+ {#each uniquePorts(container.ports) as port} + + {port.host_port}:{port.container_port} + + {/each} +
+ + + {container.compose_project || "—"} + + +
+ {@render ActionBtn({ label: "Logs", variant: "cyan", + loading: false, + onclick: () => openLogs(agent_id, container.id, container.name) })} + {#if container.state !== "running"} + {@render ActionBtn({ label: "Start", variant: "green", + loading: actionPending === container.id, + onclick: () => doAction(agent_id, container.id, "start") })} + {:else} + {@render ActionBtn({ label: "Stop", variant: "ghost", + loading: actionPending === container.id, + onclick: () => doAction(agent_id, container.id, "stop") })} + {@render ActionBtn({ label: "Restart", variant: "ghost", + loading: actionPending === container.id, + onclick: () => doAction(agent_id, container.id, "restart") })} + {/if} +
+ + + {/each} + + + - -
- {#each containers as { agent_id, container } (container.id)} -
-
-
- - {container.name} + +
+ {#each containers as { agent_id, container } (container.id)} +
+
+
+ + {container.name} +
+ {container.state}
- {container.state} -
-

{container.image}

- {#if uniquePorts(container.ports).length > 0} -
- {#each uniquePorts(container.ports) as port} - - {port.host_port}:{port.container_port} - - {/each} -
- {/if} -
- {@render ActionBtn({ label: "Logs", variant: "cyan", - loading: false, - onclick: () => openLogs(agent_id, container.id, container.name) })} - {#if container.state !== "running"} - {@render ActionBtn({ label: "Start", variant: "green", - loading: actionPending === container.id, - onclick: () => doAction(agent_id, container.id, "start") })} - {:else} - {@render ActionBtn({ label: "Stop", variant: "ghost", - loading: actionPending === container.id, - onclick: () => doAction(agent_id, container.id, "stop") })} - {@render ActionBtn({ label: "Restart", variant: "ghost", - loading: actionPending === container.id, - onclick: () => doAction(agent_id, container.id, "restart") })} +

{container.image}

+ {#if uniquePorts(container.ports).length > 0} +
+ {#each uniquePorts(container.ports) as port} + + {port.host_port}:{port.container_port} + + {/each} +
{/if} +
+ {@render ActionBtn({ label: "Logs", variant: "cyan", + loading: false, + onclick: () => openLogs(agent_id, container.id, container.name) })} + {#if container.state !== "running"} + {@render ActionBtn({ label: "Start", variant: "green", + loading: actionPending === container.id, + onclick: () => doAction(agent_id, container.id, "start") })} + {:else} + {@render ActionBtn({ label: "Stop", variant: "ghost", + loading: actionPending === container.id, + onclick: () => doAction(agent_id, container.id, "stop") })} + {@render ActionBtn({ label: "Restart", variant: "ghost", + loading: actionPending === container.id, + onclick: () => doAction(agent_id, container.id, "restart") })} + {/if} +
-
- {/each} -
- {/if} + {/each} +
+ {/if} -
- {/if} - {/each} +
+ {/if} + {/each} + {/if} + + + {:else if activeTab === "images"} + {#if images === null} +
+
+ Chargement… +
+ + {:else if loadError} +
+ +

{loadError}

+
+ + {:else if sortedAgentImages.length === 0} +
+ + + + Aucune image disponible +
+ + {:else} + {#each sortedAgentImages as [agentId, agentImages]} + {@const first = agentImages[0]} + {@const isCollapsed = collapsedImages[agentId] ?? true} +
+ + + + {#if !isCollapsed} +
+ + + + + + + + + + + {#each agentImages as img (img.id)} + + + + + + + {/each} + +
TagsIDTailleDate
+ {#if img.tags.length > 0} +
+ {#each img.tags as tag} + + {tag} + + {/each} +
+ {:else} + <none> + {/if} +
{shortId(img.id)}{formatSize(img.size)}{formatDate(img.created_at)}
+
+ {/if} + +
+ {/each} + {/if} + + + {:else if activeTab === "volumes"} + {#if volumes === null} +
+
+ Chargement… +
+ + {:else if loadError} +
+ +

{loadError}

+
+ + {:else if sortedAgentVolumes.length === 0} +
+ + + + Aucun volume disponible +
+ + {:else} + {#each sortedAgentVolumes as [agentId, agentVolumes]} + {@const first = agentVolumes[0]} + {@const isCollapsed = collapsedVolumes[agentId] ?? true} +
+ + + + {#if !isCollapsed} +
+ + + + + + + + + + {#each agentVolumes as vol (vol.name)} + + + + + + {/each} + +
NomDriverMountpoint
{vol.name}{vol.driver}{vol.mountpoint}
+
+ {/if} + +
+ {/each} + {/if} + + + {:else if activeTab === "networks"} + {#if networks === null} +
+
+ Chargement… +
+ + {:else if loadError} +
+ +

{loadError}

+
+ + {:else if sortedAgentNetworks.length === 0} +
+ + + + Aucun réseau disponible +
+ + {:else} + {#each sortedAgentNetworks as [agentId, agentNetworks]} + {@const first = agentNetworks[0]} + {@const isCollapsed = collapsedNetworks[agentId] ?? true} +
+ + + + {#if !isCollapsed} +
+ + + + + + + + + + + {#each agentNetworks as net (net.id)} + + + + + + + {/each} + +
NomDriverScopeID
{net.name}{net.driver} + + {net.scope} + + {shortId(net.id)}
+
+ {/if} + +
+ {/each} + {/if} {/if} + {/key} +