From 35643b2ea90f713b78d1f9ead3724505757e6d6a Mon Sep 17 00:00:00 2001 From: Blomios Date: Wed, 20 May 2026 08:48:14 +0200 Subject: [PATCH] fix: fix mobile view + add orphean deletions --- agent/src/docker.rs | 152 +++++++- agent/src/main.rs | 169 +++++++++ proto/agent/v1/agent.proto | 49 ++- server/internal/api/api_test.go | 246 +++++++++++++ server/internal/api/handlers.go | 70 ++++ server/internal/api/router.go | 3 + web/src/lib/api.ts | 24 ++ web/src/routes/+page.svelte | 621 ++++++++++++++++++++++++++------ 8 files changed, 1185 insertions(+), 149 deletions(-) diff --git a/agent/src/docker.rs b/agent/src/docker.rs index a80bf46..f4eddf0 100644 --- a/agent/src/docker.rs +++ b/agent/src/docker.rs @@ -4,9 +4,9 @@ use bollard::{ ListContainersOptions, LogOutput, LogsOptions, RemoveContainerOptions, StartContainerOptions, StopContainerOptions, }, - image::ListImagesOptions, + image::{ListImagesOptions, RemoveImageOptions}, network::ListNetworksOptions, - volume::ListVolumesOptions, + volume::{ListVolumesOptions, RemoveVolumeOptions}, Docker, }; use futures_util::Stream; @@ -31,6 +31,23 @@ pub trait ContainerBackend: Clone + Send + Sync + 'static { fn list_volumes(&self) -> impl std::future::Future>> + Send; fn list_networks(&self) -> impl std::future::Future>> + Send; + fn remove_image( + &self, + image_id: &str, + force: bool, + ) -> impl std::future::Future> + Send; + + fn remove_volume( + &self, + volume_name: &str, + force: bool, + ) -> impl std::future::Future> + Send; + + fn remove_network( + &self, + network_id: &str, + ) -> impl std::future::Future> + Send; + fn logs( &self, id: &str, @@ -146,54 +163,139 @@ impl ContainerBackend for DockerClient { } async fn list_images(&self) -> Result> { + // Fetch all containers to cross-reference image IDs + let containers = self + .inner + .list_containers(Some(ListContainersOptions:: { + all: true, + ..Default::default() + })) + .await + .unwrap_or_default(); + + // Collect the set of image IDs currently used by containers + let used_image_ids: std::collections::HashSet = containers + .iter() + .filter_map(|c| c.image_id.clone()) + .collect(); + 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, + .map(|c| { + let is_orphan = !used_image_ids.contains(&c.id); + ImageInfo { + id: c.id, + tags: c.repo_tags, + size: c.size, + created_at: c.created, + is_orphan, + } }) .collect()) } async fn list_volumes(&self) -> Result> { + // Fetch containers to cross-reference volume mounts + let containers = self + .inner + .list_containers(Some(ListContainersOptions:: { + all: true, + ..Default::default() + })) + .await + .unwrap_or_default(); + + // Collect volume names that are mounted in at least one container + let used_volumes: std::collections::HashSet = containers + .iter() + .flat_map(|c| c.mounts.iter().flatten()) + .filter_map(|m| m.name.clone()) + .collect(); + 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, + .map(|v| { + let is_orphan = !used_volumes.contains(&v.name); + VolumeInfo { + name: v.name, + driver: v.driver, + mountpoint: v.mountpoint, + is_orphan, + } }) .collect()) } async fn list_networks(&self) -> Result> { + const SYSTEM_NETWORKS: &[&str] = &["bridge", "host", "none"]; + 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(), + .map(|n| { + let name = n.name.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; + NetworkInfo { + id: n.id.unwrap_or_default(), + name, + driver: n.driver.unwrap_or_default(), + scope: n.scope.unwrap_or_default(), + is_orphan, + } }) .collect()) } + async fn remove_image(&self, image_id: &str, force: bool) -> Result<()> { + self.inner + .remove_image( + image_id, + Some(RemoveImageOptions { + force, + noprune: false, + }), + None, + ) + .await?; + Ok(()) + } + + async fn remove_volume(&self, volume_name: &str, force: bool) -> Result<()> { + self.inner + .remove_volume(volume_name, Some(RemoveVolumeOptions { force })) + .await?; + Ok(()) + } + + async fn remove_network(&self, network_id: &str) -> Result<()> { + self.inner.remove_network(network_id).await?; + Ok(()) + } + fn logs( &self, id: &str, @@ -294,6 +396,7 @@ pub mod tests { tags: vec!["nginx:latest".to_string()], size: 1024, created_at: 1_700_000_000, + is_orphan: false, }]) } @@ -304,6 +407,7 @@ pub mod tests { name: "data".to_string(), driver: "local".to_string(), mountpoint: "/var/lib/docker/volumes/data/_data".to_string(), + is_orphan: false, }]) } @@ -315,9 +419,25 @@ pub mod tests { name: "bridge".to_string(), driver: "bridge".to_string(), scope: "local".to_string(), + is_orphan: false, }]) } + async fn remove_image(&self, image_id: &str, _force: bool) -> Result<()> { + self.record(format!("remove_image:{image_id}")); + self.maybe_err() + } + + async fn remove_volume(&self, volume_name: &str, _force: bool) -> Result<()> { + self.record(format!("remove_volume:{volume_name}")); + self.maybe_err() + } + + async fn remove_network(&self, network_id: &str) -> Result<()> { + self.record(format!("remove_network:{network_id}")); + self.maybe_err() + } + async fn start(&self, id: &str) -> Result<()> { self.record(format!("start:{id}")); self.maybe_err() diff --git a/agent/src/main.rs b/agent/src/main.rs index 7f5656a..9cc946a 100644 --- a/agent/src/main.rs +++ b/agent/src/main.rs @@ -338,6 +338,69 @@ async fn run(url: &str, token: &str, hostname: &str, docker: DockerClient) -> Re }).await; }); } + Some(server_message::Payload::DeleteImage(cmd)) => { + let tx_clone = tx.clone(); + let docker_clone = docker.clone(); + tokio::spawn(async move { + let result = docker_clone.remove_image(&cmd.image_id, cmd.force).await; + let (success, error) = match result { + Ok(()) => (true, String::new()), + Err(e) => { + warn!("remove_image {} failed: {:#}", cmd.image_id, e); + (false, e.to_string()) + } + }; + let _ = tx_clone.send(AgentMessage { + payload: Some(agent_message::Payload::Result(proto::CommandResult { + command_id: cmd.command_id, + success, + error, + })), + }).await; + }); + } + Some(server_message::Payload::DeleteVolume(cmd)) => { + let tx_clone = tx.clone(); + let docker_clone = docker.clone(); + tokio::spawn(async move { + let result = docker_clone.remove_volume(&cmd.volume_name, cmd.force).await; + let (success, error) = match result { + Ok(()) => (true, String::new()), + Err(e) => { + warn!("remove_volume {} failed: {:#}", cmd.volume_name, e); + (false, e.to_string()) + } + }; + let _ = tx_clone.send(AgentMessage { + payload: Some(agent_message::Payload::Result(proto::CommandResult { + command_id: cmd.command_id, + success, + error, + })), + }).await; + }); + } + Some(server_message::Payload::DeleteNetwork(cmd)) => { + let tx_clone = tx.clone(); + let docker_clone = docker.clone(); + tokio::spawn(async move { + let result = docker_clone.remove_network(&cmd.network_id).await; + let (success, error) = match result { + Ok(()) => (true, String::new()), + Err(e) => { + warn!("remove_network {} failed: {:#}", cmd.network_id, e); + (false, e.to_string()) + } + }; + let _ = tx_clone.send(AgentMessage { + payload: Some(agent_message::Payload::Result(proto::CommandResult { + command_id: cmd.command_id, + success, + error, + })), + }).await; + }); + } None => {} } } @@ -1157,4 +1220,110 @@ mod tests { Some(agent_message::Payload::UpdateCheckResult(_)) )); } + + // ── DeleteImage / DeleteVolume / DeleteNetwork via MockBackend ──────────── + + #[tokio::test] + async fn mock_remove_image_records_call() { + let backend = MockBackend::new(); + backend.remove_image("sha256:abc", false).await.unwrap(); + assert_eq!(*backend.calls.lock().unwrap(), vec!["remove_image:sha256:abc"]); + } + + #[tokio::test] + async fn mock_remove_image_force_records_call() { + let backend = MockBackend::new(); + backend.remove_image("sha256:abc", true).await.unwrap(); + assert_eq!(*backend.calls.lock().unwrap(), vec!["remove_image:sha256:abc"]); + } + + #[tokio::test] + async fn mock_remove_image_propagates_error() { + let backend = MockBackend::failing("image in use"); + let err = backend.remove_image("sha256:abc", false).await.unwrap_err(); + assert!(err.to_string().contains("image in use")); + } + + #[tokio::test] + async fn mock_remove_volume_records_call() { + let backend = MockBackend::new(); + backend.remove_volume("my-vol", false).await.unwrap(); + assert_eq!(*backend.calls.lock().unwrap(), vec!["remove_volume:my-vol"]); + } + + #[tokio::test] + async fn mock_remove_volume_propagates_error() { + let backend = MockBackend::failing("volume in use"); + let err = backend.remove_volume("my-vol", false).await.unwrap_err(); + assert!(err.to_string().contains("volume in use")); + } + + #[tokio::test] + async fn mock_remove_network_records_call() { + let backend = MockBackend::new(); + backend.remove_network("net-abc").await.unwrap(); + assert_eq!(*backend.calls.lock().unwrap(), vec!["remove_network:net-abc"]); + } + + #[tokio::test] + async fn mock_remove_network_propagates_error() { + let backend = MockBackend::failing("network active"); + let err = backend.remove_network("net-abc").await.unwrap_err(); + assert!(err.to_string().contains("network active")); + } + + // ── is_orphan field on proto structs ────────────────────────────────────── + + #[test] + fn image_info_orphan_field() { + let orphan = ImageInfo { + id: "sha256:orphan".to_string(), + tags: vec![], + size: 512, + created_at: 0, + is_orphan: true, + }; + assert!(orphan.is_orphan); + + let used = ImageInfo { + id: "sha256:used".to_string(), + tags: vec!["app:latest".to_string()], + size: 1024, + created_at: 0, + is_orphan: false, + }; + assert!(!used.is_orphan); + } + + #[test] + fn volume_info_orphan_field() { + let orphan = VolumeInfo { + name: "orphan-vol".to_string(), + driver: "local".to_string(), + mountpoint: "/var/lib/docker/volumes/orphan-vol/_data".to_string(), + is_orphan: true, + }; + assert!(orphan.is_orphan); + } + + #[test] + fn network_info_orphan_field() { + let orphan = NetworkInfo { + id: "net-orphan".to_string(), + name: "my-unused-net".to_string(), + driver: "bridge".to_string(), + scope: "local".to_string(), + is_orphan: true, + }; + assert!(orphan.is_orphan); + + let system = NetworkInfo { + id: "net-bridge".to_string(), + name: "bridge".to_string(), + driver: "bridge".to_string(), + scope: "local".to_string(), + is_orphan: false, + }; + assert!(!system.is_orphan); + } } diff --git a/proto/agent/v1/agent.proto b/proto/agent/v1/agent.proto index 01effc6..ccc4482 100644 --- a/proto/agent/v1/agent.proto +++ b/proto/agent/v1/agent.proto @@ -40,19 +40,22 @@ message ImageInfo { repeated string tags = 2; int64 size = 3; int64 created_at = 4; + bool is_orphan = 5; } message VolumeInfo { string name = 1; string driver = 2; string mountpoint = 3; + bool is_orphan = 4; } message NetworkInfo { - string id = 1; - string name = 2; - string driver = 3; - string scope = 4; + string id = 1; + string name = 2; + string driver = 3; + string scope = 4; + bool is_orphan = 5; } message ContainerSnapshot { @@ -163,17 +166,37 @@ message UpdateContainerCommand { string container_id = 2; } +message DeleteImageCommand { + string command_id = 1; + string image_id = 2; + bool force = 3; +} + +message DeleteVolumeCommand { + string command_id = 1; + string volume_name = 2; + bool force = 3; +} + +message DeleteNetworkCommand { + string command_id = 1; + string network_id = 2; +} + message ServerMessage { oneof payload { - ContainerCommand container_cmd = 1; - StreamLogsCommand stream_logs = 2; - ListDirCommand list_dir = 3; - ReadFileCommand read_file = 4; - WriteFileCommand write_file = 5; - ExecComposeCommand exec_compose = 6; - CreateDirCommand create_dir = 7; - CheckUpdateCommand check_update = 8; - UpdateContainerCommand update_container = 9; + ContainerCommand container_cmd = 1; + StreamLogsCommand stream_logs = 2; + ListDirCommand list_dir = 3; + ReadFileCommand read_file = 4; + WriteFileCommand write_file = 5; + ExecComposeCommand exec_compose = 6; + CreateDirCommand create_dir = 7; + CheckUpdateCommand check_update = 8; + UpdateContainerCommand update_container = 9; + DeleteImageCommand delete_image = 10; + DeleteVolumeCommand delete_volume = 11; + DeleteNetworkCommand delete_network = 12; } } diff --git a/server/internal/api/api_test.go b/server/internal/api/api_test.go index 5f1ee2d..0cd46c3 100644 --- a/server/internal/api/api_test.go +++ b/server/internal/api/api_test.go @@ -1045,3 +1045,249 @@ func TestComposeAction_Timeout(t *testing.T) { t.Errorf("expected 504 or 404, got %d", w.Code) } } + +// ── ListImages is_orphan ────────────────────────────────────────────────────── + +func TestListImages_IsOrphan(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:orphan", Tags: []string{}, Size: 10000, CreatedAt: 1700000000, IsOrphan: true}, + {Id: "sha256:used", Tags: []string{"app:latest"}, Size: 20000, CreatedAt: 1700000001, IsOrphan: false}, + }, + 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 { + ID string `json:"id"` + IsOrphan bool `json:"is_orphan"` + } + json.NewDecoder(w.Body).Decode(&out) + + if len(out) != 2 { + t.Fatalf("expected 2 images, got %d", len(out)) + } + for _, item := range out { + if item.ID == "sha256:orphan" && !item.IsOrphan { + t.Error("expected sha256:orphan to have is_orphan=true") + } + if item.ID == "sha256:used" && item.IsOrphan { + t.Error("expected sha256:used to have is_orphan=false") + } + } +} + +// ── ListVolumes is_orphan ───────────────────────────────────────────────────── + +func TestListVolumes_IsOrphan(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: "orphaned-vol", Driver: "local", Mountpoint: "/var/lib/docker/volumes/orphaned-vol/_data", IsOrphan: true}, + {Name: "active-vol", Driver: "local", Mountpoint: "/var/lib/docker/volumes/active-vol/_data", IsOrphan: false}, + }, + 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 { + Name string `json:"name"` + IsOrphan bool `json:"is_orphan"` + } + json.NewDecoder(w.Body).Decode(&out) + + if len(out) != 2 { + t.Fatalf("expected 2 volumes, got %d", len(out)) + } + for _, item := range out { + if item.Name == "orphaned-vol" && !item.IsOrphan { + t.Error("expected orphaned-vol to have is_orphan=true") + } + if item.Name == "active-vol" && item.IsOrphan { + t.Error("expected active-vol to have is_orphan=false") + } + } +} + +// ── ListNetworks is_orphan ──────────────────────────────────────────────────── + +func TestListNetworks_IsOrphan(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: "net-orphan", Name: "stale-net", Driver: "bridge", Scope: "local", IsOrphan: true}, + {Id: "net-used", Name: "active-net", Driver: "bridge", Scope: "local", IsOrphan: false}, + }, + ) + + 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 { + ID string `json:"id"` + IsOrphan bool `json:"is_orphan"` + } + json.NewDecoder(w.Body).Decode(&out) + + if len(out) != 2 { + t.Fatalf("expected 2 networks, got %d", len(out)) + } + for _, item := range out { + if item.ID == "net-orphan" && !item.IsOrphan { + t.Error("expected net-orphan to have is_orphan=true") + } + if item.ID == "net-used" && item.IsOrphan { + t.Error("expected net-used to have is_orphan=false") + } + } +} + +// ── DeleteImage ─────────────────────────────────────────────────────────────── + +func TestDeleteImage_AgentNotConnected(t *testing.T) { + h, _, _, _ := newTestHandler(t) + + router := chi.NewRouter() + router.Delete("/api/v1/agents/{agentID}/images/{imageID}", h.DeleteImage) + + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/ghost/images/sha256:abc", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } +} + +func TestDeleteImage_Success(t *testing.T) { + h, _, reg, _ := newTestHandler(t) + reg.Register("a1", "h", "a", "ip", "arch", "os") + + router := chi.NewRouter() + router.Delete("/api/v1/agents/{agentID}/images/{imageID}", h.DeleteImage) + + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/a1/images/sha256:abc?force=true", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["command_id"] == "" { + t.Error("expected command_id in response") + } +} + +// ── DeleteVolume ────────────────────────────────────────────────────────────── + +func TestDeleteVolume_AgentNotConnected(t *testing.T) { + h, _, _, _ := newTestHandler(t) + + router := chi.NewRouter() + router.Delete("/api/v1/agents/{agentID}/volumes/{volumeName}", h.DeleteVolume) + + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/ghost/volumes/my-vol", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } +} + +func TestDeleteVolume_Success(t *testing.T) { + h, _, reg, _ := newTestHandler(t) + reg.Register("a1", "h", "a", "ip", "arch", "os") + + router := chi.NewRouter() + router.Delete("/api/v1/agents/{agentID}/volumes/{volumeName}", h.DeleteVolume) + + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/a1/volumes/my-vol", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["command_id"] == "" { + t.Error("expected command_id in response") + } +} + +// ── DeleteNetwork ───────────────────────────────────────────────────────────── + +func TestDeleteNetwork_AgentNotConnected(t *testing.T) { + h, _, _, _ := newTestHandler(t) + + router := chi.NewRouter() + router.Delete("/api/v1/agents/{agentID}/networks/{networkID}", h.DeleteNetwork) + + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/ghost/networks/net1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusServiceUnavailable { + t.Errorf("expected 503, got %d", w.Code) + } +} + +func TestDeleteNetwork_Success(t *testing.T) { + h, _, reg, _ := newTestHandler(t) + reg.Register("a1", "h", "a", "ip", "arch", "os") + + router := chi.NewRouter() + router.Delete("/api/v1/agents/{agentID}/networks/{networkID}", h.DeleteNetwork) + + req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/a1/networks/net1", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String()) + } + + var resp map[string]string + json.NewDecoder(w.Body).Decode(&resp) + if resp["command_id"] == "" { + t.Error("expected command_id in response") + } +} diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index e5d02f3..734d947 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -191,6 +191,7 @@ func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) { Tags []string `json:"tags"` Size int64 `json:"size"` CreatedAt int64 `json:"created_at"` + IsOrphan bool `json:"is_orphan"` } var out []imageDTO for _, agent := range h.registry.List() { @@ -204,12 +205,35 @@ func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) { Tags: func() []string { if t := img.GetTags(); t != nil { return t }; return []string{} }(), Size: img.GetSize(), CreatedAt: img.GetCreatedAt(), + IsOrphan: img.GetIsOrphan(), }) } } jsonOK(w, out) } +func (h *Handler) DeleteImage(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + imageID := chi.URLParam(r, "imageID") + force := r.URL.Query().Get("force") == "true" + + cmdID := uuid.NewString() + sent := h.registry.Send(agentID, &agentv1.ServerMessage{ + Payload: &agentv1.ServerMessage_DeleteImage{ + DeleteImage: &agentv1.DeleteImageCommand{ + CommandId: cmdID, + ImageId: imageID, + Force: force, + }, + }, + }) + if !sent { + http.Error(w, "agent not connected", http.StatusServiceUnavailable) + return + } + jsonOK(w, map[string]string{"command_id": cmdID}) +} + // ── Volumes ─────────────────────────────────────────────────────────────────── func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) { @@ -221,6 +245,7 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) { Name string `json:"name"` Driver string `json:"driver"` Mountpoint string `json:"mountpoint"` + IsOrphan bool `json:"is_orphan"` } var out []volumeDTO for _, agent := range h.registry.List() { @@ -233,12 +258,35 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) { Name: vol.GetName(), Driver: vol.GetDriver(), Mountpoint: vol.GetMountpoint(), + IsOrphan: vol.GetIsOrphan(), }) } } jsonOK(w, out) } +func (h *Handler) DeleteVolume(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + volumeName := chi.URLParam(r, "volumeName") + force := r.URL.Query().Get("force") == "true" + + cmdID := uuid.NewString() + sent := h.registry.Send(agentID, &agentv1.ServerMessage{ + Payload: &agentv1.ServerMessage_DeleteVolume{ + DeleteVolume: &agentv1.DeleteVolumeCommand{ + CommandId: cmdID, + VolumeName: volumeName, + Force: force, + }, + }, + }) + if !sent { + http.Error(w, "agent not connected", http.StatusServiceUnavailable) + return + } + jsonOK(w, map[string]string{"command_id": cmdID}) +} + // ── Networks ────────────────────────────────────────────────────────────────── func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) { @@ -251,6 +299,7 @@ func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) { Name string `json:"name"` Driver string `json:"driver"` Scope string `json:"scope"` + IsOrphan bool `json:"is_orphan"` } var out []networkDTO for _, agent := range h.registry.List() { @@ -264,12 +313,33 @@ func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) { Name: net.GetName(), Driver: net.GetDriver(), Scope: net.GetScope(), + IsOrphan: net.GetIsOrphan(), }) } } jsonOK(w, out) } +func (h *Handler) DeleteNetwork(w http.ResponseWriter, r *http.Request) { + agentID := chi.URLParam(r, "agentID") + networkID := chi.URLParam(r, "networkID") + + cmdID := uuid.NewString() + sent := h.registry.Send(agentID, &agentv1.ServerMessage{ + Payload: &agentv1.ServerMessage_DeleteNetwork{ + DeleteNetwork: &agentv1.DeleteNetworkCommand{ + CommandId: cmdID, + NetworkId: networkID, + }, + }, + }) + if !sent { + http.Error(w, "agent not connected", http.StatusServiceUnavailable) + return + } + jsonOK(w, map[string]string{"command_id": cmdID}) +} + // ── 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 25f8a92..d3ddeaa 100644 --- a/server/internal/api/router.go +++ b/server/internal/api/router.go @@ -50,6 +50,9 @@ func NewRouter(h *Handler) http.Handler { r.Get("/volumes", h.ListVolumes) r.Get("/networks", h.ListNetworks) r.Post("/agents/{agentID}/containers/{containerID}/action", h.ContainerAction) + r.Delete("/agents/{agentID}/images/{imageID}", h.DeleteImage) + r.Delete("/agents/{agentID}/volumes/{volumeName}", h.DeleteVolume) + r.Delete("/agents/{agentID}/networks/{networkID}", h.DeleteNetwork) r.Get("/agents/{agentID}/containers/{containerID}/logs", h.LogsWS) r.Get("/events", h.EventsWS) r.Get("/agents/{agentID}/fs/list", h.FsList) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 54cfbea..e2892fd 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -127,6 +127,7 @@ export interface ImageEntry { tags: string[]; size: number; created_at: number; + is_orphan: boolean; } export interface VolumeEntry { @@ -137,6 +138,7 @@ export interface VolumeEntry { name: string; driver: string; mountpoint: string; + is_orphan: boolean; } export interface NetworkEntry { @@ -148,6 +150,7 @@ export interface NetworkEntry { name: string; driver: string; scope: string; + is_orphan: boolean; } export async function fetchImages(): Promise { @@ -186,6 +189,27 @@ export async function fetchNetworks(): Promise { } } +export async function deleteImage(agentId: string, imageId: string, force = true): Promise { + const r = await apiFetch(`${BASE}/agents/${agentId}/images/${encodeURIComponent(imageId)}?force=${force}`, { + method: "DELETE", + }); + if (!r.ok) throw new Error(`deleteImage: ${r.status}`); +} + +export async function deleteVolume(agentId: string, volumeName: string): Promise { + const r = await apiFetch(`${BASE}/agents/${agentId}/volumes/${encodeURIComponent(volumeName)}`, { + method: "DELETE", + }); + if (!r.ok) throw new Error(`deleteVolume: ${r.status}`); +} + +export async function deleteNetwork(agentId: string, networkId: string): Promise { + const r = await apiFetch(`${BASE}/agents/${agentId}/networks/${encodeURIComponent(networkId)}`, { + method: "DELETE", + }); + if (!r.ok) throw new Error(`deleteNetwork: ${r.status}`); +} + export function connectLogs( agentId: string, containerId: string, diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte index 7a5a2c7..34b64ea 100644 --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -16,6 +16,9 @@ getAutoUpdatePolicy, setAutoUpdatePolicy, updateNow, + deleteImage, + deleteVolume, + deleteNetwork, type ContainerEntry, type ContainerPort, type ImageEntry, @@ -70,6 +73,106 @@ let collapsedNetworks= $state>({}); let collapsedProjects = $state>({}); let projectActionPending = $state(null); // `${agentId}/${projectName}` + let openKebab = $state(null); + + // ── Orphan / delete state ───────────────────────────────────────────────── + let deletePending = $state(null); + let showOrphansOnlyImages = $state(false); + let showOrphansOnlyVolumes = $state(false); + let showOrphansOnlyNetworks = $state(false); + + async function doDeleteImage(agentId: string, imageId: string) { + if (!confirm("Supprimer cette image ?")) return; + deletePending = imageId; + try { + await deleteImage(agentId, imageId, true); + showToast("Image supprimée", true); + await loadImages(); + } catch (e: unknown) { + showToast(e instanceof Error ? e.message : String(e), false); + } finally { + deletePending = null; + } + } + + async function doDeleteVolume(agentId: string, volumeName: string) { + if (!confirm(`Supprimer le volume "${volumeName}" ?`)) return; + deletePending = volumeName; + try { + await deleteVolume(agentId, volumeName); + showToast("Volume supprimé", true); + await loadVolumes(); + } catch (e: unknown) { + showToast(e instanceof Error ? e.message : String(e), false); + } finally { + deletePending = null; + } + } + + async function doDeleteNetwork(agentId: string, networkId: string) { + if (!confirm("Supprimer ce réseau ?")) return; + deletePending = networkId; + try { + await deleteNetwork(agentId, networkId); + showToast("Réseau supprimé", true); + await loadNetworks(); + } catch (e: unknown) { + showToast(e instanceof Error ? e.message : String(e), false); + } finally { + deletePending = null; + } + } + + async function pruneAllImages(agentId: string, agentImages: ImageEntry[]) { + const orphans = agentImages.filter(i => i.is_orphan); + if (orphans.length === 0) return; + if (!confirm(`Supprimer ${orphans.length} image(s) orpheline(s) ?`)) return; + for (const img of orphans) { + deletePending = img.id; + try { + await deleteImage(agentId, img.id, true); + } catch (e: unknown) { + showToast(e instanceof Error ? e.message : String(e), false); + } + } + deletePending = null; + showToast("Images orphelines supprimées", true); + await loadImages(); + } + + async function pruneAllVolumes(agentId: string, agentVolumes: VolumeEntry[]) { + const orphans = agentVolumes.filter(v => v.is_orphan); + if (orphans.length === 0) return; + if (!confirm(`Supprimer ${orphans.length} volume(s) orphelin(s) ?`)) return; + for (const vol of orphans) { + deletePending = vol.name; + try { + await deleteVolume(agentId, vol.name); + } catch (e: unknown) { + showToast(e instanceof Error ? e.message : String(e), false); + } + } + deletePending = null; + showToast("Volumes orphelins supprimés", true); + await loadVolumes(); + } + + async function pruneAllNetworks(agentId: string, agentNetworks: NetworkEntry[]) { + const orphans = agentNetworks.filter(n => n.is_orphan); + if (orphans.length === 0) return; + if (!confirm(`Supprimer ${orphans.length} réseau(x) orphelin(s) ?`)) return; + for (const net of orphans) { + deletePending = net.id; + try { + await deleteNetwork(agentId, net.id); + } catch (e: unknown) { + showToast(e instanceof Error ? e.message : String(e), false); + } + } + deletePending = null; + showToast("Réseaux orphelins supprimés", true); + await loadNetworks(); + } // Group auto-update interface GroupAutoUpdateState { @@ -664,24 +767,24 @@
{#if activeTab === "containers" && entries !== null} - + {:else if activeTab === "images"} {#if images !== null} - + {/if} {:else if activeTab === "volumes"} {#if volumes !== null} - + {/if} {:else if activeTab === "networks"} {#if networks !== null} - + {/if} @@ -729,7 +832,7 @@
-
@@ -1127,77 +1273,156 @@
{:else} + +
+ +
+ {#each sortedAgentImages as [agentId, agentImages]} + {@const displayedImages = showOrphansOnlyImages ? agentImages.filter(i => i.is_orphan) : agentImages} + {#if displayedImages.length > 0} {@const first = agentImages[0]}
- + {#if agentImages.some(i => i.is_orphan)} + {/if} - {#if first.ip_address} - {first.ip_address} - {/if} - - {agentImages.length} image{agentImages.length !== 1 ? "s" : ""} - - + {#if collapsedImages[agentId] === false}
- + +
+ - {#each agentImages.slice().sort((a, b) => (a.tags[0] ?? a.id).localeCompare(b.tags[0] ?? b.id)) as img (img.id)} - + {#each displayedImages.slice().sort((a, b) => (a.tags[0] ?? a.id).localeCompare(b.tags[0] ?? b.id)) as img (img.id)} + + {/each} + + +
+ {#each displayedImages.slice().sort((a, b) => (a.tags[0] ?? a.id).localeCompare(b.tags[0] ?? b.id)) as img (img.id)} +
+
+
+ {#if img.tags?.length > 0} + {#each img.tags as tag} + {tag} + {/each} + {:else} + <none> + {/if} + {#if img.is_orphan} + orphan + {/if} +
+ {#if img.is_orphan} + + {/if} +
+
+ {shortId(img.id)} + {formatSize(img.size)} + {formatDate(img.created_at)} +
+
+ {/each} +
{/if}
+ {/if} {/each} {/if} @@ -1227,63 +1452,139 @@ {:else} + +
+ +
+ {#each sortedAgentVolumes as [agentId, agentVolumes]} + {@const displayedVolumes = showOrphansOnlyVolumes ? agentVolumes.filter(v => v.is_orphan) : agentVolumes} + {#if displayedVolumes.length > 0} {@const first = agentVolumes[0]}
- + {#if agentVolumes.some(v => v.is_orphan)} + {/if} - {#if first.ip_address} - {first.ip_address} - {/if} - - {agentVolumes.length} volume{agentVolumes.length !== 1 ? "s" : ""} - - + {#if collapsedVolumes[agentId] === false}
- + +
+ - {#each agentVolumes.slice().sort((a, b) => a.name.localeCompare(b.name)) as vol (vol.name)} - - + {#each displayedVolumes.slice().sort((a, b) => a.name.localeCompare(b.name)) as vol (vol.name)} + + + {/each} + + +
+ {#each displayedVolumes.slice().sort((a, b) => a.name.localeCompare(b.name)) as vol (vol.name)} +
+
+
+ {vol.name} + {#if vol.is_orphan} + orphan + {/if} +
+ {#if vol.is_orphan} + + {/if} +
+
+ {vol.driver} + {vol.mountpoint} +
+
+ {/each} +
{/if}
+ {/if} {/each} {/if} @@ -1313,53 +1614,88 @@ {:else} + +
+ +
+ {#each sortedAgentNetworks as [agentId, agentNetworks]} + {@const displayedNetworks = showOrphansOnlyNetworks ? agentNetworks.filter(n => n.is_orphan) : agentNetworks} + {#if displayedNetworks.length > 0} {@const first = agentNetworks[0]}
- + {#if agentNetworks.some(n => n.is_orphan)} + {/if} - {#if first.ip_address} - {first.ip_address} - {/if} - - {agentNetworks.length} réseau{agentNetworks.length !== 1 ? "x" : ""} - - + {#if collapsedNetworks[agentId] === false}
- + +
+ - {#each agentNetworks.slice().sort((a, b) => a.name.localeCompare(b.name)) as net (net.id)} - - + {#each displayedNetworks.slice().sort((a, b) => a.name.localeCompare(b.name)) as net (net.id)} + + + {/each} + + +
+ {#each displayedNetworks.slice().sort((a, b) => a.name.localeCompare(b.name)) as net (net.id)} +
+
+
+ {net.name} + {#if net.is_orphan} + orphan + {/if} +
+ {#if net.is_orphan} + + {/if} +
+
+ {net.driver} + {net.scope} + {shortId(net.id)} +
+
+ {/each} +
{/if}
+ {/if} {/each} {/if} {/if}