fix: fix mobile view + add orphean deletions

This commit is contained in:
2026-05-20 08:48:14 +02:00
parent b3176c4dfa
commit 35643b2ea9
8 changed files with 1185 additions and 149 deletions

View File

@ -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<Output = Result<Vec<VolumeInfo>>> + Send;
fn list_networks(&self) -> impl std::future::Future<Output = Result<Vec<NetworkInfo>>> + Send;
fn remove_image(
&self,
image_id: &str,
force: bool,
) -> impl std::future::Future<Output = Result<()>> + Send;
fn remove_volume(
&self,
volume_name: &str,
force: bool,
) -> impl std::future::Future<Output = Result<()>> + Send;
fn remove_network(
&self,
network_id: &str,
) -> impl std::future::Future<Output = Result<()>> + Send;
fn logs(
&self,
id: &str,
@ -146,54 +163,139 @@ impl ContainerBackend for DockerClient {
}
async fn list_images(&self) -> Result<Vec<ImageInfo>> {
// Fetch all containers to cross-reference image IDs
let containers = self
.inner
.list_containers(Some(ListContainersOptions::<String> {
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<String> = containers
.iter()
.filter_map(|c| c.image_id.clone())
.collect();
let images = self
.inner
.list_images(None::<ListImagesOptions<String>>)
.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<Vec<VolumeInfo>> {
// Fetch containers to cross-reference volume mounts
let containers = self
.inner
.list_containers(Some(ListContainersOptions::<String> {
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<String> = containers
.iter()
.flat_map(|c| c.mounts.iter().flatten())
.filter_map(|m| m.name.clone())
.collect();
let info = self
.inner
.list_volumes(None::<ListVolumesOptions<String>>)
.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<Vec<NetworkInfo>> {
const SYSTEM_NETWORKS: &[&str] = &["bridge", "host", "none"];
let networks = self
.inner
.list_networks(None::<ListNetworksOptions<String>>)
.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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<ImageEntry[]> {
@ -186,6 +189,27 @@ export async function fetchNetworks(): Promise<NetworkEntry[]> {
}
}
export async function deleteImage(agentId: string, imageId: string, force = true): Promise<void> {
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<void> {
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<void> {
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,

View File

@ -16,6 +16,9 @@
getAutoUpdatePolicy,
setAutoUpdatePolicy,
updateNow,
deleteImage,
deleteVolume,
deleteNetwork,
type ContainerEntry,
type ContainerPort,
type ImageEntry,
@ -70,6 +73,106 @@
let collapsedNetworks= $state<Record<string, boolean>>({});
let collapsedProjects = $state<Record<string, boolean>>({});
let projectActionPending = $state<string | null>(null); // `${agentId}/${projectName}`
let openKebab = $state<string | null>(null);
// ── Orphan / delete state ─────────────────────────────────────────────────
let deletePending = $state<string | null>(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 @@
<div class="ml-auto flex items-center gap-1">
{#if activeTab === "containers" && entries !== null}
<span class="text-xs text-slate-500 mr-3 tabular-nums">
<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="text-xs text-slate-500 mr-3 tabular-nums">
<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="text-xs text-slate-500 mr-3 tabular-nums">
<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="text-xs text-slate-500 mr-3 tabular-nums">
<span class="hidden sm:inline text-xs text-slate-500 mr-3 tabular-nums">
{networks.length} networks · {Object.keys(byAgentNetworks).length} hosts
</span>
{/if}
@ -729,7 +832,7 @@
<!-- Tab bar -->
<div class="border-b border-white/[0.06] bg-abyss-900/80 px-4 md:px-6">
<nav class="flex max-w-7xl mx-auto -mb-px">
<nav class="flex max-w-7xl mx-auto -mb-px overflow-x-auto">
{#each ([
{ id: "containers", label: "Containers" },
{ id: "images", label: "Images" },
@ -738,7 +841,7 @@
] as { id: Tab; label: string }[]) as tab}
<button
onclick={() => switchTab(tab.id)}
class="px-4 py-3 text-sm font-medium border-b-2 transition-colors
class="px-3 sm:px-4 py-3 text-sm font-medium border-b-2 shrink-0 transition-colors
{activeTab === tab.id
? 'border-cyan-400 text-cyan-400'
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-600'}"
@ -798,10 +901,10 @@
{first.alias || first.hostname}
</h2>
{#if first.alias}
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
<span class="hidden sm:inline font-mono text-xs text-slate-600">{first.hostname}</span>
{/if}
{#if first.ip_address}
<span class="font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
<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]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
@ -838,7 +941,7 @@
<span class="text-xs font-semibold text-slate-300 group-hover/proj:text-slate-100 transition-colors font-mono">
{projectName}
</span>
<span class="text-xs text-slate-600">
<span class="hidden sm:inline text-xs text-slate-600">
{projectContainers.length} container{projectContainers.length !== 1 ? 's' : ''}
</span>
</button>
@ -847,25 +950,68 @@
<div class="flex items-center gap-1 shrink-0">
{@render ActionBtn({ label: "Logs", variant: "cyan", loading: false,
onclick: () => openProjectLogs(agentId, projectName, projectContainers) })}
{@render ActionBtn({ label: "Stop All", variant: "ghost", loading: isProjPending,
onclick: () => doProjectAction(agentId, projectName, projectContainers, 'stop') })}
{@render ActionBtn({ label: "Restart All", variant: "ghost", loading: isProjPending,
onclick: () => doProjectAction(agentId, projectName, projectContainers, 'restart') })}
<!-- Auto-update groupe -->
<button
data-group-autoupdate-btn
onclick={(e) => { e.stopPropagation(); openGroupAutoUpdate(e, agentId, projectName, projectContainers); }}
title="Auto-update groupe"
class="px-2 py-1 rounded-lg text-xs font-medium transition-all border
{isGroupAutoUpdateOpen || anyAutoUpdateEnabled
? 'bg-violet-500/20 text-violet-300 border-violet-500/40'
: 'bg-white/[0.05] hover:bg-white/[0.09] text-slate-500 hover:text-slate-300 border-white/[0.08]'}"
>
<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="2"
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>
<!-- Desktop : boutons complets -->
<div class="hidden sm:flex items-center gap-1">
{@render ActionBtn({ label: "Stop All", variant: "ghost", loading: isProjPending,
onclick: () => doProjectAction(agentId, projectName, projectContainers, 'stop') })}
{@render ActionBtn({ label: "Restart All", variant: "ghost", loading: isProjPending,
onclick: () => doProjectAction(agentId, projectName, projectContainers, 'restart') })}
<button
data-group-autoupdate-btn
onclick={(e) => { e.stopPropagation(); openGroupAutoUpdate(e, agentId, projectName, projectContainers); }}
title="Auto-update groupe"
class="px-2 py-1 rounded-lg text-xs font-medium transition-all border
{isGroupAutoUpdateOpen || anyAutoUpdateEnabled
? 'bg-violet-500/20 text-violet-300 border-violet-500/40'
: 'bg-white/[0.05] hover:bg-white/[0.09] text-slate-500 hover:text-slate-300 border-white/[0.08]'}"
>
<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="2"
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>
<!-- Mobile : kebab menu -->
<div class="relative sm:hidden">
<button
onclick={(e) => { e.stopPropagation(); openKebab = openKebab === projKey ? null : projKey; }}
class="px-2 py-1 rounded-lg text-xs font-medium transition-all border
bg-white/[0.05] hover:bg-white/[0.09] text-slate-500 hover:text-slate-300 border-white/[0.08]"
title="Plus d'actions"
>
<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="3" d="M12 5v.01M12 12v.01M12 19v.01" />
</svg>
</button>
{#if openKebab === projKey}
<!-- Overlay pour fermer -->
<button
class="fixed inset-0 z-40"
onclick={() => openKebab = null}
aria-label="Fermer le menu"
></button>
<!-- Dropdown -->
<div class="absolute right-0 top-full mt-1 z-50 min-w-[140px] bg-abyss-800 border border-white/[0.08] rounded-lg shadow-xl overflow-hidden">
<button
class="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-white/[0.06] transition-colors"
onclick={(e) => { e.stopPropagation(); doProjectAction(agentId, projectName, projectContainers, 'stop'); openKebab = null; }}
>Stop All</button>
<button
class="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-white/[0.06] transition-colors"
onclick={(e) => { e.stopPropagation(); doProjectAction(agentId, projectName, projectContainers, 'restart'); openKebab = null; }}
>Restart All</button>
<button
data-group-autoupdate-btn
class="w-full text-left px-3 py-2 text-xs transition-colors border-t border-white/[0.04]
{anyAutoUpdateEnabled ? 'text-violet-300 hover:bg-violet-500/10' : 'text-slate-300 hover:bg-white/[0.06]'}"
onclick={(e) => { e.stopPropagation(); openKebab = null; openGroupAutoUpdate(e, agentId, projectName, projectContainers); }}
>Auto-update</button>
</div>
{/if}
</div>
</div>
</div>
@ -1127,77 +1273,156 @@
</div>
{:else}
<!-- Orphans filter toggle -->
<div class="flex items-center justify-end mb-4">
<button
onclick={() => showOrphansOnlyImages = !showOrphansOnlyImages}
class="text-xs px-2.5 py-1 rounded-lg border transition-colors
{showOrphansOnlyImages
? 'bg-red-500/15 text-red-400 border-red-500/30'
: 'bg-white/[0.05] text-slate-400 border-white/[0.08] hover:bg-white/[0.09]'}"
>
{showOrphansOnlyImages ? "Toutes les images" : "Orphelins seulement"}
</button>
</div>
{#each sortedAgentImages as [agentId, agentImages]}
{@const displayedImages = showOrphansOnlyImages ? agentImages.filter(i => i.is_orphan) : agentImages}
{#if displayedImages.length > 0}
{@const first = agentImages[0]}
<section class="mb-8">
<button
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
onclick={() => toggleImages(agentId)}
type="button"
>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{collapsedImages[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
<div class="flex items-center gap-2.5 mb-3 px-1">
<button
class="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
onclick={() => toggleImages(agentId)}
type="button"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
</svg>
<span class="dot-running"></span>
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
{first.alias || first.hostname}
</h2>
{#if first.alias}
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{collapsedImages[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
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="dot-running"></span>
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
{first.alias || first.hostname}
</h2>
{#if first.alias}
<span class="hidden sm:inline font-mono text-xs text-slate-600">{first.hostname}</span>
{/if}
{#if first.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]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
{agentImages.length} image{agentImages.length !== 1 ? "s" : ""}
</span>
</button>
{#if agentImages.some(i => i.is_orphan)}
<button
onclick={() => pruneAllImages(agentId, agentImages)}
class="shrink-0 text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors"
>
Prune orphans
</button>
{/if}
{#if first.ip_address}
<span class="font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
border border-white/[0.06]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
{agentImages.length} image{agentImages.length !== 1 ? "s" : ""}
</span>
</button>
</div>
{#if collapsedImages[agentId] === false}
<div class="card overflow-hidden">
<table class="w-full text-sm">
<!-- Desktop table -->
<table class="hidden md:table w-full text-sm">
<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">Tags</th>
<th class="px-4 py-3 text-left font-medium">ID</th>
<th class="px-4 py-3 text-left font-medium">Taille</th>
<th class="px-4 py-3 text-left font-medium">Date</th>
<th class="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#each agentImages.slice().sort((a, b) => (a.tags[0] ?? a.id).localeCompare(b.tags[0] ?? b.id)) as img (img.id)}
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors">
{#each displayedImages.slice().sort((a, b) => (a.tags[0] ?? a.id).localeCompare(b.tags[0] ?? b.id)) as img (img.id)}
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors
{img.is_orphan ? 'bg-red-500/[0.03]' : ''}">
<td class="px-4 py-3">
{#if img.tags?.length > 0}
<div class="flex flex-wrap gap-1">
<div class="flex flex-wrap items-center gap-1">
{#if img.tags?.length > 0}
{#each img.tags as tag}
<span class="font-mono text-xs px-1.5 py-0.5 rounded
bg-cyan-400/10 text-cyan-400 border border-cyan-400/20">
{tag}
</span>
{/each}
</div>
{:else}
<span class="text-xs text-slate-600 italic">&lt;none&gt;</span>
{/if}
{:else}
<span class="text-xs text-slate-600 italic">&lt;none&gt;</span>
{/if}
{#if img.is_orphan}
<span class="text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">orphan</span>
{/if}
</div>
</td>
<td class="px-4 py-3 font-mono text-xs text-slate-500">{shortId(img.id)}</td>
<td class="px-4 py-3 text-xs text-slate-400 tabular-nums">{formatSize(img.size)}</td>
<td class="px-4 py-3 text-xs text-slate-500">{formatDate(img.created_at)}</td>
<td class="px-4 py-3 text-right">
{#if img.is_orphan}
<button
onclick={() => doDeleteImage(agentId, img.id)}
disabled={deletePending === img.id}
class="text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
>
{deletePending === img.id ? "…" : "Delete"}
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<!-- Mobile cards -->
<div class="md:hidden divide-y divide-white/[0.04]">
{#each displayedImages.slice().sort((a, b) => (a.tags[0] ?? a.id).localeCompare(b.tags[0] ?? b.id)) as img (img.id)}
<div class="p-4 {img.is_orphan ? 'bg-red-500/[0.03]' : ''}">
<div class="flex items-start justify-between gap-2 mb-1">
<div class="flex flex-wrap items-center gap-1 min-w-0">
{#if img.tags?.length > 0}
{#each img.tags as tag}
<span class="font-mono text-xs px-1.5 py-0.5 rounded bg-cyan-400/10 text-cyan-400 border border-cyan-400/20">{tag}</span>
{/each}
{:else}
<span class="text-xs text-slate-600 italic">&lt;none&gt;</span>
{/if}
{#if img.is_orphan}
<span class="text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">orphan</span>
{/if}
</div>
{#if img.is_orphan}
<button
onclick={() => doDeleteImage(agentId, img.id)}
disabled={deletePending === img.id}
class="shrink-0 text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
>
{deletePending === img.id ? "…" : "Delete"}
</button>
{/if}
</div>
<div class="flex items-center gap-3 text-xs text-slate-500 mt-1">
<span class="font-mono">{shortId(img.id)}</span>
<span class="tabular-nums text-slate-400">{formatSize(img.size)}</span>
<span>{formatDate(img.created_at)}</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
</section>
{/if}
{/each}
{/if}
@ -1227,63 +1452,139 @@
</div>
{:else}
<!-- Orphans filter toggle -->
<div class="flex items-center justify-end mb-4">
<button
onclick={() => showOrphansOnlyVolumes = !showOrphansOnlyVolumes}
class="text-xs px-2.5 py-1 rounded-lg border transition-colors
{showOrphansOnlyVolumes
? 'bg-red-500/15 text-red-400 border-red-500/30'
: 'bg-white/[0.05] text-slate-400 border-white/[0.08] hover:bg-white/[0.09]'}"
>
{showOrphansOnlyVolumes ? "Tous les volumes" : "Orphelins seulement"}
</button>
</div>
{#each sortedAgentVolumes as [agentId, agentVolumes]}
{@const displayedVolumes = showOrphansOnlyVolumes ? agentVolumes.filter(v => v.is_orphan) : agentVolumes}
{#if displayedVolumes.length > 0}
{@const first = agentVolumes[0]}
<section class="mb-8">
<button
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
onclick={() => toggleVolumes(agentId)}
type="button"
>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{collapsedVolumes[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
<div class="flex items-center gap-2.5 mb-3 px-1">
<button
class="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
onclick={() => toggleVolumes(agentId)}
type="button"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
</svg>
<span class="dot-running"></span>
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
{first.alias || first.hostname}
</h2>
{#if first.alias}
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{collapsedVolumes[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
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="dot-running"></span>
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
{first.alias || first.hostname}
</h2>
{#if first.alias}
<span class="hidden sm:inline font-mono text-xs text-slate-600">{first.hostname}</span>
{/if}
{#if first.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]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
{agentVolumes.length} volume{agentVolumes.length !== 1 ? "s" : ""}
</span>
</button>
{#if agentVolumes.some(v => v.is_orphan)}
<button
onclick={() => pruneAllVolumes(agentId, agentVolumes)}
class="shrink-0 text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors"
>
Prune orphans
</button>
{/if}
{#if first.ip_address}
<span class="font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
border border-white/[0.06]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
{agentVolumes.length} volume{agentVolumes.length !== 1 ? "s" : ""}
</span>
</button>
</div>
{#if collapsedVolumes[agentId] === false}
<div class="card overflow-hidden">
<table class="w-full text-sm">
<!-- Desktop table -->
<table class="hidden md:table w-full text-sm">
<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">Nom</th>
<th class="px-4 py-3 text-left font-medium">Driver</th>
<th class="px-4 py-3 text-left font-medium">Mountpoint</th>
<th class="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#each agentVolumes.slice().sort((a, b) => a.name.localeCompare(b.name)) as vol (vol.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-200 font-medium">{vol.name}</td>
{#each displayedVolumes.slice().sort((a, b) => a.name.localeCompare(b.name)) as vol (vol.name)}
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors
{vol.is_orphan ? 'bg-red-500/[0.03]' : ''}">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-slate-200 font-medium">{vol.name}</span>
{#if vol.is_orphan}
<span class="text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">orphan</span>
{/if}
</div>
</td>
<td class="px-4 py-3 text-xs text-slate-400">{vol.driver}</td>
<td class="px-4 py-3 font-mono text-xs text-slate-500 max-w-xs truncate"
title={vol.mountpoint}>{vol.mountpoint}</td>
<td class="px-4 py-3 text-right">
{#if vol.is_orphan}
<button
onclick={() => doDeleteVolume(agentId, vol.name)}
disabled={deletePending === vol.name}
class="text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
>
{deletePending === vol.name ? "…" : "Delete"}
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<!-- Mobile cards -->
<div class="md:hidden divide-y divide-white/[0.04]">
{#each displayedVolumes.slice().sort((a, b) => a.name.localeCompare(b.name)) as vol (vol.name)}
<div class="p-4 {vol.is_orphan ? 'bg-red-500/[0.03]' : ''}">
<div class="flex items-start justify-between gap-2 mb-1">
<div class="flex items-center gap-2 min-w-0">
<span class="font-mono text-xs font-medium text-slate-200 truncate">{vol.name}</span>
{#if vol.is_orphan}
<span class="shrink-0 text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">orphan</span>
{/if}
</div>
{#if vol.is_orphan}
<button
onclick={() => doDeleteVolume(agentId, vol.name)}
disabled={deletePending === vol.name}
class="shrink-0 text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
>
{deletePending === vol.name ? "…" : "Delete"}
</button>
{/if}
</div>
<div class="flex items-center gap-3 text-xs text-slate-500 mt-1">
<span>{vol.driver}</span>
<span class="font-mono truncate">{vol.mountpoint}</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
</section>
{/if}
{/each}
{/if}
@ -1313,53 +1614,88 @@
</div>
{:else}
<!-- Orphans filter toggle -->
<div class="flex items-center justify-end mb-4">
<button
onclick={() => showOrphansOnlyNetworks = !showOrphansOnlyNetworks}
class="text-xs px-2.5 py-1 rounded-lg border transition-colors
{showOrphansOnlyNetworks
? 'bg-red-500/15 text-red-400 border-red-500/30'
: 'bg-white/[0.05] text-slate-400 border-white/[0.08] hover:bg-white/[0.09]'}"
>
{showOrphansOnlyNetworks ? "Tous les réseaux" : "Orphelins seulement"}
</button>
</div>
{#each sortedAgentNetworks as [agentId, agentNetworks]}
{@const displayedNetworks = showOrphansOnlyNetworks ? agentNetworks.filter(n => n.is_orphan) : agentNetworks}
{#if displayedNetworks.length > 0}
{@const first = agentNetworks[0]}
<section class="mb-8">
<button
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
onclick={() => toggleNetworks(agentId)}
type="button"
>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{collapsedNetworks[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
<div class="flex items-center gap-2.5 mb-3 px-1">
<button
class="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
onclick={() => toggleNetworks(agentId)}
type="button"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
</svg>
<span class="dot-running"></span>
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
{first.alias || first.hostname}
</h2>
{#if first.alias}
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
<svg
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
{collapsedNetworks[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
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="dot-running"></span>
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
{first.alias || first.hostname}
</h2>
{#if first.alias}
<span class="hidden sm:inline font-mono text-xs text-slate-600">{first.hostname}</span>
{/if}
{#if first.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]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
{agentNetworks.length} réseau{agentNetworks.length !== 1 ? "x" : ""}
</span>
</button>
{#if agentNetworks.some(n => n.is_orphan)}
<button
onclick={() => pruneAllNetworks(agentId, agentNetworks)}
class="shrink-0 text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors"
>
Prune orphans
</button>
{/if}
{#if first.ip_address}
<span class="font-mono text-xs text-slate-500 bg-abyss-700 px-2 py-0.5 rounded-full
border border-white/[0.06]">{first.ip_address}</span>
{/if}
<span class="text-xs text-slate-600 ml-auto">
{agentNetworks.length} réseau{agentNetworks.length !== 1 ? "x" : ""}
</span>
</button>
</div>
{#if collapsedNetworks[agentId] === false}
<div class="card overflow-hidden">
<table class="w-full text-sm">
<!-- Desktop table -->
<table class="hidden md:table w-full text-sm">
<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">Nom</th>
<th class="px-4 py-3 text-left font-medium">Driver</th>
<th class="px-4 py-3 text-left font-medium">Scope</th>
<th class="px-4 py-3 text-left font-medium">ID</th>
<th class="px-4 py-3 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{#each agentNetworks.slice().sort((a, b) => a.name.localeCompare(b.name)) as net (net.id)}
<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-200 font-medium">{net.name}</td>
{#each displayedNetworks.slice().sort((a, b) => a.name.localeCompare(b.name)) as net (net.id)}
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors
{net.is_orphan ? 'bg-red-500/[0.03]' : ''}">
<td class="px-4 py-3">
<div class="flex items-center gap-2">
<span class="font-mono text-xs text-slate-200 font-medium">{net.name}</span>
{#if net.is_orphan}
<span class="text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">orphan</span>
{/if}
</div>
</td>
<td class="px-4 py-3 text-xs text-slate-400">{net.driver}</td>
<td class="px-4 py-3 text-xs">
<span class="px-1.5 py-0.5 rounded text-xs
@ -1370,14 +1706,59 @@
</span>
</td>
<td class="px-4 py-3 font-mono text-xs text-slate-600">{shortId(net.id)}</td>
<td class="px-4 py-3 text-right">
{#if net.is_orphan}
<button
onclick={() => doDeleteNetwork(agentId, net.id)}
disabled={deletePending === net.id}
class="text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
>
{deletePending === net.id ? "…" : "Delete"}
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
<!-- Mobile cards -->
<div class="md:hidden divide-y divide-white/[0.04]">
{#each displayedNetworks.slice().sort((a, b) => a.name.localeCompare(b.name)) as net (net.id)}
<div class="p-4 {net.is_orphan ? 'bg-red-500/[0.03]' : ''}">
<div class="flex items-start justify-between gap-2 mb-1">
<div class="flex items-center gap-2 min-w-0">
<span class="font-mono text-xs font-medium text-slate-200 truncate">{net.name}</span>
{#if net.is_orphan}
<span class="shrink-0 text-xs px-1.5 py-0.5 rounded bg-red-500/10 text-red-400 border border-red-500/20">orphan</span>
{/if}
</div>
{#if net.is_orphan}
<button
onclick={() => doDeleteNetwork(agentId, net.id)}
disabled={deletePending === net.id}
class="shrink-0 text-xs px-2 py-1 rounded border border-red-500/30 text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
>
{deletePending === net.id ? "…" : "Delete"}
</button>
{/if}
</div>
<div class="flex items-center gap-3 text-xs text-slate-500 mt-1">
<span>{net.driver}</span>
<span class="px-1.5 py-0.5 rounded
{net.scope === 'local'
? 'bg-slate-700/60 text-slate-400'
: 'bg-cyan-400/10 text-cyan-400 border border-cyan-400/20'}">{net.scope}</span>
<span class="font-mono">{shortId(net.id)}</span>
</div>
</div>
{/each}
</div>
</div>
{/if}
</section>
{/if}
{/each}
{/if}
{/if}