fix: fix mobile view + add orphean deletions
This commit is contained in:
@ -4,9 +4,9 @@ use bollard::{
|
|||||||
ListContainersOptions, LogOutput, LogsOptions, RemoveContainerOptions,
|
ListContainersOptions, LogOutput, LogsOptions, RemoveContainerOptions,
|
||||||
StartContainerOptions, StopContainerOptions,
|
StartContainerOptions, StopContainerOptions,
|
||||||
},
|
},
|
||||||
image::ListImagesOptions,
|
image::{ListImagesOptions, RemoveImageOptions},
|
||||||
network::ListNetworksOptions,
|
network::ListNetworksOptions,
|
||||||
volume::ListVolumesOptions,
|
volume::{ListVolumesOptions, RemoveVolumeOptions},
|
||||||
Docker,
|
Docker,
|
||||||
};
|
};
|
||||||
use futures_util::Stream;
|
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_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 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(
|
fn logs(
|
||||||
&self,
|
&self,
|
||||||
id: &str,
|
id: &str,
|
||||||
@ -146,54 +163,139 @@ impl ContainerBackend for DockerClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn list_images(&self) -> Result<Vec<ImageInfo>> {
|
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
|
let images = self
|
||||||
.inner
|
.inner
|
||||||
.list_images(None::<ListImagesOptions<String>>)
|
.list_images(None::<ListImagesOptions<String>>)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(images
|
Ok(images
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|c| ImageInfo {
|
.map(|c| {
|
||||||
id: c.id,
|
let is_orphan = !used_image_ids.contains(&c.id);
|
||||||
tags: c.repo_tags,
|
ImageInfo {
|
||||||
size: c.size,
|
id: c.id,
|
||||||
created_at: c.created,
|
tags: c.repo_tags,
|
||||||
|
size: c.size,
|
||||||
|
created_at: c.created,
|
||||||
|
is_orphan,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_volumes(&self) -> Result<Vec<VolumeInfo>> {
|
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
|
let info = self
|
||||||
.inner
|
.inner
|
||||||
.list_volumes(None::<ListVolumesOptions<String>>)
|
.list_volumes(None::<ListVolumesOptions<String>>)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(info
|
Ok(info
|
||||||
.volumes
|
.volumes
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|v| VolumeInfo {
|
.map(|v| {
|
||||||
name: v.name,
|
let is_orphan = !used_volumes.contains(&v.name);
|
||||||
driver: v.driver,
|
VolumeInfo {
|
||||||
mountpoint: v.mountpoint,
|
name: v.name,
|
||||||
|
driver: v.driver,
|
||||||
|
mountpoint: v.mountpoint,
|
||||||
|
is_orphan,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_networks(&self) -> Result<Vec<NetworkInfo>> {
|
async fn list_networks(&self) -> Result<Vec<NetworkInfo>> {
|
||||||
|
const SYSTEM_NETWORKS: &[&str] = &["bridge", "host", "none"];
|
||||||
|
|
||||||
let networks = self
|
let networks = self
|
||||||
.inner
|
.inner
|
||||||
.list_networks(None::<ListNetworksOptions<String>>)
|
.list_networks(None::<ListNetworksOptions<String>>)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(networks
|
Ok(networks
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|n| NetworkInfo {
|
.map(|n| {
|
||||||
id: n.id.unwrap_or_default(),
|
let name = n.name.clone().unwrap_or_default();
|
||||||
name: n.name.unwrap_or_default(),
|
let is_system = SYSTEM_NETWORKS.contains(&name.as_str());
|
||||||
driver: n.driver.unwrap_or_default(),
|
// A network is orphan if it is not a system network and has no
|
||||||
scope: n.scope.unwrap_or_default(),
|
// 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())
|
.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(
|
fn logs(
|
||||||
&self,
|
&self,
|
||||||
id: &str,
|
id: &str,
|
||||||
@ -294,6 +396,7 @@ pub mod tests {
|
|||||||
tags: vec!["nginx:latest".to_string()],
|
tags: vec!["nginx:latest".to_string()],
|
||||||
size: 1024,
|
size: 1024,
|
||||||
created_at: 1_700_000_000,
|
created_at: 1_700_000_000,
|
||||||
|
is_orphan: false,
|
||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -304,6 +407,7 @@ pub mod tests {
|
|||||||
name: "data".to_string(),
|
name: "data".to_string(),
|
||||||
driver: "local".to_string(),
|
driver: "local".to_string(),
|
||||||
mountpoint: "/var/lib/docker/volumes/data/_data".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(),
|
name: "bridge".to_string(),
|
||||||
driver: "bridge".to_string(),
|
driver: "bridge".to_string(),
|
||||||
scope: "local".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<()> {
|
async fn start(&self, id: &str) -> Result<()> {
|
||||||
self.record(format!("start:{id}"));
|
self.record(format!("start:{id}"));
|
||||||
self.maybe_err()
|
self.maybe_err()
|
||||||
|
|||||||
@ -338,6 +338,69 @@ async fn run(url: &str, token: &str, hostname: &str, docker: DockerClient) -> Re
|
|||||||
}).await;
|
}).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 => {}
|
None => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1157,4 +1220,110 @@ mod tests {
|
|||||||
Some(agent_message::Payload::UpdateCheckResult(_))
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,19 +40,22 @@ message ImageInfo {
|
|||||||
repeated string tags = 2;
|
repeated string tags = 2;
|
||||||
int64 size = 3;
|
int64 size = 3;
|
||||||
int64 created_at = 4;
|
int64 created_at = 4;
|
||||||
|
bool is_orphan = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message VolumeInfo {
|
message VolumeInfo {
|
||||||
string name = 1;
|
string name = 1;
|
||||||
string driver = 2;
|
string driver = 2;
|
||||||
string mountpoint = 3;
|
string mountpoint = 3;
|
||||||
|
bool is_orphan = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
message NetworkInfo {
|
message NetworkInfo {
|
||||||
string id = 1;
|
string id = 1;
|
||||||
string name = 2;
|
string name = 2;
|
||||||
string driver = 3;
|
string driver = 3;
|
||||||
string scope = 4;
|
string scope = 4;
|
||||||
|
bool is_orphan = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message ContainerSnapshot {
|
message ContainerSnapshot {
|
||||||
@ -163,17 +166,37 @@ message UpdateContainerCommand {
|
|||||||
string container_id = 2;
|
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 {
|
message ServerMessage {
|
||||||
oneof payload {
|
oneof payload {
|
||||||
ContainerCommand container_cmd = 1;
|
ContainerCommand container_cmd = 1;
|
||||||
StreamLogsCommand stream_logs = 2;
|
StreamLogsCommand stream_logs = 2;
|
||||||
ListDirCommand list_dir = 3;
|
ListDirCommand list_dir = 3;
|
||||||
ReadFileCommand read_file = 4;
|
ReadFileCommand read_file = 4;
|
||||||
WriteFileCommand write_file = 5;
|
WriteFileCommand write_file = 5;
|
||||||
ExecComposeCommand exec_compose = 6;
|
ExecComposeCommand exec_compose = 6;
|
||||||
CreateDirCommand create_dir = 7;
|
CreateDirCommand create_dir = 7;
|
||||||
CheckUpdateCommand check_update = 8;
|
CheckUpdateCommand check_update = 8;
|
||||||
UpdateContainerCommand update_container = 9;
|
UpdateContainerCommand update_container = 9;
|
||||||
|
DeleteImageCommand delete_image = 10;
|
||||||
|
DeleteVolumeCommand delete_volume = 11;
|
||||||
|
DeleteNetworkCommand delete_network = 12;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1045,3 +1045,249 @@ func TestComposeAction_Timeout(t *testing.T) {
|
|||||||
t.Errorf("expected 504 or 404, got %d", w.Code)
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -191,6 +191,7 @@ func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) {
|
|||||||
Tags []string `json:"tags"`
|
Tags []string `json:"tags"`
|
||||||
Size int64 `json:"size"`
|
Size int64 `json:"size"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
IsOrphan bool `json:"is_orphan"`
|
||||||
}
|
}
|
||||||
var out []imageDTO
|
var out []imageDTO
|
||||||
for _, agent := range h.registry.List() {
|
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{} }(),
|
Tags: func() []string { if t := img.GetTags(); t != nil { return t }; return []string{} }(),
|
||||||
Size: img.GetSize(),
|
Size: img.GetSize(),
|
||||||
CreatedAt: img.GetCreatedAt(),
|
CreatedAt: img.GetCreatedAt(),
|
||||||
|
IsOrphan: img.GetIsOrphan(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
jsonOK(w, out)
|
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 ───────────────────────────────────────────────────────────────────
|
// ── Volumes ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) {
|
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"`
|
Name string `json:"name"`
|
||||||
Driver string `json:"driver"`
|
Driver string `json:"driver"`
|
||||||
Mountpoint string `json:"mountpoint"`
|
Mountpoint string `json:"mountpoint"`
|
||||||
|
IsOrphan bool `json:"is_orphan"`
|
||||||
}
|
}
|
||||||
var out []volumeDTO
|
var out []volumeDTO
|
||||||
for _, agent := range h.registry.List() {
|
for _, agent := range h.registry.List() {
|
||||||
@ -233,12 +258,35 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) {
|
|||||||
Name: vol.GetName(),
|
Name: vol.GetName(),
|
||||||
Driver: vol.GetDriver(),
|
Driver: vol.GetDriver(),
|
||||||
Mountpoint: vol.GetMountpoint(),
|
Mountpoint: vol.GetMountpoint(),
|
||||||
|
IsOrphan: vol.GetIsOrphan(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
jsonOK(w, out)
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Networks ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
|
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"`
|
Name string `json:"name"`
|
||||||
Driver string `json:"driver"`
|
Driver string `json:"driver"`
|
||||||
Scope string `json:"scope"`
|
Scope string `json:"scope"`
|
||||||
|
IsOrphan bool `json:"is_orphan"`
|
||||||
}
|
}
|
||||||
var out []networkDTO
|
var out []networkDTO
|
||||||
for _, agent := range h.registry.List() {
|
for _, agent := range h.registry.List() {
|
||||||
@ -264,12 +313,33 @@ func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
|
|||||||
Name: net.GetName(),
|
Name: net.GetName(),
|
||||||
Driver: net.GetDriver(),
|
Driver: net.GetDriver(),
|
||||||
Scope: net.GetScope(),
|
Scope: net.GetScope(),
|
||||||
|
IsOrphan: net.GetIsOrphan(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
jsonOK(w, out)
|
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 ──────────────────────────────────────────────────
|
// ── Agent token provisioning ──────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *Handler) CreateAgentToken(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) CreateAgentToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@ -50,6 +50,9 @@ func NewRouter(h *Handler) http.Handler {
|
|||||||
r.Get("/volumes", h.ListVolumes)
|
r.Get("/volumes", h.ListVolumes)
|
||||||
r.Get("/networks", h.ListNetworks)
|
r.Get("/networks", h.ListNetworks)
|
||||||
r.Post("/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
|
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("/agents/{agentID}/containers/{containerID}/logs", h.LogsWS)
|
||||||
r.Get("/events", h.EventsWS)
|
r.Get("/events", h.EventsWS)
|
||||||
r.Get("/agents/{agentID}/fs/list", h.FsList)
|
r.Get("/agents/{agentID}/fs/list", h.FsList)
|
||||||
|
|||||||
@ -127,6 +127,7 @@ export interface ImageEntry {
|
|||||||
tags: string[];
|
tags: string[];
|
||||||
size: number;
|
size: number;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
|
is_orphan: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VolumeEntry {
|
export interface VolumeEntry {
|
||||||
@ -137,6 +138,7 @@ export interface VolumeEntry {
|
|||||||
name: string;
|
name: string;
|
||||||
driver: string;
|
driver: string;
|
||||||
mountpoint: string;
|
mountpoint: string;
|
||||||
|
is_orphan: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NetworkEntry {
|
export interface NetworkEntry {
|
||||||
@ -148,6 +150,7 @@ export interface NetworkEntry {
|
|||||||
name: string;
|
name: string;
|
||||||
driver: string;
|
driver: string;
|
||||||
scope: string;
|
scope: string;
|
||||||
|
is_orphan: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchImages(): Promise<ImageEntry[]> {
|
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(
|
export function connectLogs(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
containerId: string,
|
containerId: string,
|
||||||
|
|||||||
@ -16,6 +16,9 @@
|
|||||||
getAutoUpdatePolicy,
|
getAutoUpdatePolicy,
|
||||||
setAutoUpdatePolicy,
|
setAutoUpdatePolicy,
|
||||||
updateNow,
|
updateNow,
|
||||||
|
deleteImage,
|
||||||
|
deleteVolume,
|
||||||
|
deleteNetwork,
|
||||||
type ContainerEntry,
|
type ContainerEntry,
|
||||||
type ContainerPort,
|
type ContainerPort,
|
||||||
type ImageEntry,
|
type ImageEntry,
|
||||||
@ -70,6 +73,106 @@
|
|||||||
let collapsedNetworks= $state<Record<string, boolean>>({});
|
let collapsedNetworks= $state<Record<string, boolean>>({});
|
||||||
let collapsedProjects = $state<Record<string, boolean>>({});
|
let collapsedProjects = $state<Record<string, boolean>>({});
|
||||||
let projectActionPending = $state<string | null>(null); // `${agentId}/${projectName}`
|
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
|
// Group auto-update
|
||||||
interface GroupAutoUpdateState {
|
interface GroupAutoUpdateState {
|
||||||
@ -664,24 +767,24 @@
|
|||||||
|
|
||||||
<div class="ml-auto flex items-center gap-1">
|
<div class="ml-auto flex items-center gap-1">
|
||||||
{#if activeTab === "containers" && entries !== null}
|
{#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
|
{entries.length} containers · {Object.keys(byAgent).length} hosts
|
||||||
</span>
|
</span>
|
||||||
{:else if activeTab === "images"}
|
{:else if activeTab === "images"}
|
||||||
{#if images !== null}
|
{#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
|
{images.length} images · {Object.keys(byAgentImages).length} hosts
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if activeTab === "volumes"}
|
{:else if activeTab === "volumes"}
|
||||||
{#if volumes !== null}
|
{#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
|
{volumes.length} volumes · {Object.keys(byAgentVolumes).length} hosts
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{:else if activeTab === "networks"}
|
{:else if activeTab === "networks"}
|
||||||
{#if networks !== null}
|
{#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
|
{networks.length} networks · {Object.keys(byAgentNetworks).length} hosts
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
@ -729,7 +832,7 @@
|
|||||||
|
|
||||||
<!-- Tab bar -->
|
<!-- Tab bar -->
|
||||||
<div class="border-b border-white/[0.06] bg-abyss-900/80 px-4 md:px-6">
|
<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 ([
|
{#each ([
|
||||||
{ id: "containers", label: "Containers" },
|
{ id: "containers", label: "Containers" },
|
||||||
{ id: "images", label: "Images" },
|
{ id: "images", label: "Images" },
|
||||||
@ -738,7 +841,7 @@
|
|||||||
] as { id: Tab; label: string }[]) as tab}
|
] as { id: Tab; label: string }[]) as tab}
|
||||||
<button
|
<button
|
||||||
onclick={() => switchTab(tab.id)}
|
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
|
{activeTab === tab.id
|
||||||
? 'border-cyan-400 text-cyan-400'
|
? 'border-cyan-400 text-cyan-400'
|
||||||
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-600'}"
|
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-600'}"
|
||||||
@ -798,10 +901,10 @@
|
|||||||
{first.alias || first.hostname}
|
{first.alias || first.hostname}
|
||||||
</h2>
|
</h2>
|
||||||
{#if first.alias}
|
{#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}
|
||||||
{#if first.ip_address}
|
{#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>
|
border border-white/[0.06]">{first.ip_address}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="text-xs text-slate-600 ml-auto">
|
<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">
|
<span class="text-xs font-semibold text-slate-300 group-hover/proj:text-slate-100 transition-colors font-mono">
|
||||||
{projectName}
|
{projectName}
|
||||||
</span>
|
</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' : ''}
|
{projectContainers.length} container{projectContainers.length !== 1 ? 's' : ''}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@ -847,25 +950,68 @@
|
|||||||
<div class="flex items-center gap-1 shrink-0">
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
{@render ActionBtn({ label: "Logs", variant: "cyan", loading: false,
|
{@render ActionBtn({ label: "Logs", variant: "cyan", loading: false,
|
||||||
onclick: () => openProjectLogs(agentId, projectName, projectContainers) })}
|
onclick: () => openProjectLogs(agentId, projectName, projectContainers) })}
|
||||||
{@render ActionBtn({ label: "Stop All", variant: "ghost", loading: isProjPending,
|
|
||||||
onclick: () => doProjectAction(agentId, projectName, projectContainers, 'stop') })}
|
<!-- Desktop : boutons complets -->
|
||||||
{@render ActionBtn({ label: "Restart All", variant: "ghost", loading: isProjPending,
|
<div class="hidden sm:flex items-center gap-1">
|
||||||
onclick: () => doProjectAction(agentId, projectName, projectContainers, 'restart') })}
|
{@render ActionBtn({ label: "Stop All", variant: "ghost", loading: isProjPending,
|
||||||
<!-- Auto-update groupe -->
|
onclick: () => doProjectAction(agentId, projectName, projectContainers, 'stop') })}
|
||||||
<button
|
{@render ActionBtn({ label: "Restart All", variant: "ghost", loading: isProjPending,
|
||||||
data-group-autoupdate-btn
|
onclick: () => doProjectAction(agentId, projectName, projectContainers, 'restart') })}
|
||||||
onclick={(e) => { e.stopPropagation(); openGroupAutoUpdate(e, agentId, projectName, projectContainers); }}
|
<button
|
||||||
title="Auto-update groupe"
|
data-group-autoupdate-btn
|
||||||
class="px-2 py-1 rounded-lg text-xs font-medium transition-all border
|
onclick={(e) => { e.stopPropagation(); openGroupAutoUpdate(e, agentId, projectName, projectContainers); }}
|
||||||
{isGroupAutoUpdateOpen || anyAutoUpdateEnabled
|
title="Auto-update groupe"
|
||||||
? 'bg-violet-500/20 text-violet-300 border-violet-500/40'
|
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]'}"
|
{isGroupAutoUpdateOpen || anyAutoUpdateEnabled
|
||||||
>
|
? 'bg-violet-500/20 text-violet-300 border-violet-500/40'
|
||||||
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
: 'bg-white/[0.05] hover:bg-white/[0.09] text-slate-500 hover:text-slate-300 border-white/[0.08]'}"
|
||||||
<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 class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
</button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -1127,77 +1273,156 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else}
|
{: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]}
|
{#each sortedAgentImages as [agentId, agentImages]}
|
||||||
|
{@const displayedImages = showOrphansOnlyImages ? agentImages.filter(i => i.is_orphan) : agentImages}
|
||||||
|
{#if displayedImages.length > 0}
|
||||||
{@const first = agentImages[0]}
|
{@const first = agentImages[0]}
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
|
|
||||||
<button
|
<div class="flex items-center gap-2.5 mb-3 px-1">
|
||||||
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
|
<button
|
||||||
onclick={() => toggleImages(agentId)}
|
class="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
|
||||||
type="button"
|
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"
|
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
|
<svg
|
||||||
</svg>
|
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
|
||||||
<span class="dot-running"></span>
|
{collapsedImages[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
|
||||||
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
{first.alias || first.hostname}
|
>
|
||||||
</h2>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
|
||||||
{#if first.alias}
|
</svg>
|
||||||
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
|
<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}
|
||||||
{#if first.ip_address}
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
{#if collapsedImages[agentId] === false}
|
{#if collapsedImages[agentId] === false}
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<table class="w-full text-sm">
|
<!-- Desktop table -->
|
||||||
|
<table class="hidden md:table w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-white/[0.06] text-xs uppercase tracking-wider text-slate-600">
|
<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">Tags</th>
|
||||||
<th class="px-4 py-3 text-left font-medium">ID</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">Taille</th>
|
||||||
<th class="px-4 py-3 text-left font-medium">Date</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#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)}
|
||||||
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors">
|
<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">
|
<td class="px-4 py-3">
|
||||||
{#if img.tags?.length > 0}
|
<div class="flex flex-wrap items-center gap-1">
|
||||||
<div class="flex flex-wrap gap-1">
|
{#if img.tags?.length > 0}
|
||||||
{#each img.tags as tag}
|
{#each img.tags as tag}
|
||||||
<span class="font-mono text-xs px-1.5 py-0.5 rounded
|
<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">
|
bg-cyan-400/10 text-cyan-400 border border-cyan-400/20">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
{:else}
|
||||||
{:else}
|
<span class="text-xs text-slate-600 italic"><none></span>
|
||||||
<span class="text-xs text-slate-600 italic"><none></span>
|
{/if}
|
||||||
{/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>
|
||||||
<td class="px-4 py-3 font-mono text-xs text-slate-500">{shortId(img.id)}</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-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-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>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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"><none></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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@ -1227,63 +1452,139 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else}
|
{: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]}
|
{#each sortedAgentVolumes as [agentId, agentVolumes]}
|
||||||
|
{@const displayedVolumes = showOrphansOnlyVolumes ? agentVolumes.filter(v => v.is_orphan) : agentVolumes}
|
||||||
|
{#if displayedVolumes.length > 0}
|
||||||
{@const first = agentVolumes[0]}
|
{@const first = agentVolumes[0]}
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
|
|
||||||
<button
|
<div class="flex items-center gap-2.5 mb-3 px-1">
|
||||||
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
|
<button
|
||||||
onclick={() => toggleVolumes(agentId)}
|
class="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
|
||||||
type="button"
|
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"
|
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
|
<svg
|
||||||
</svg>
|
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
|
||||||
<span class="dot-running"></span>
|
{collapsedVolumes[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
|
||||||
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
{first.alias || first.hostname}
|
>
|
||||||
</h2>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
|
||||||
{#if first.alias}
|
</svg>
|
||||||
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
|
<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}
|
||||||
{#if first.ip_address}
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
{#if collapsedVolumes[agentId] === false}
|
{#if collapsedVolumes[agentId] === false}
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<table class="w-full text-sm">
|
<!-- Desktop table -->
|
||||||
|
<table class="hidden md:table w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-white/[0.06] text-xs uppercase tracking-wider text-slate-600">
|
<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">Nom</th>
|
||||||
<th class="px-4 py-3 text-left font-medium">Driver</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-left font-medium">Mountpoint</th>
|
||||||
|
<th class="px-4 py-3 text-right font-medium">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#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)}
|
||||||
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors">
|
<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>
|
{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 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"
|
<td class="px-4 py-3 font-mono text-xs text-slate-500 max-w-xs truncate"
|
||||||
title={vol.mountpoint}>{vol.mountpoint}</td>
|
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>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@ -1313,53 +1614,88 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{:else}
|
{: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]}
|
{#each sortedAgentNetworks as [agentId, agentNetworks]}
|
||||||
|
{@const displayedNetworks = showOrphansOnlyNetworks ? agentNetworks.filter(n => n.is_orphan) : agentNetworks}
|
||||||
|
{#if displayedNetworks.length > 0}
|
||||||
{@const first = agentNetworks[0]}
|
{@const first = agentNetworks[0]}
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
|
|
||||||
<button
|
<div class="flex items-center gap-2.5 mb-3 px-1">
|
||||||
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
|
<button
|
||||||
onclick={() => toggleNetworks(agentId)}
|
class="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
|
||||||
type="button"
|
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"
|
|
||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
|
<svg
|
||||||
</svg>
|
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
|
||||||
<span class="dot-running"></span>
|
{collapsedNetworks[agentId] !== false ? '-rotate-90' : 'rotate-0'}"
|
||||||
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
{first.alias || first.hostname}
|
>
|
||||||
</h2>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
|
||||||
{#if first.alias}
|
</svg>
|
||||||
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
|
<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}
|
||||||
{#if first.ip_address}
|
</div>
|
||||||
<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>
|
|
||||||
|
|
||||||
{#if collapsedNetworks[agentId] === false}
|
{#if collapsedNetworks[agentId] === false}
|
||||||
<div class="card overflow-hidden">
|
<div class="card overflow-hidden">
|
||||||
<table class="w-full text-sm">
|
<!-- Desktop table -->
|
||||||
|
<table class="hidden md:table w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="border-b border-white/[0.06] text-xs uppercase tracking-wider text-slate-600">
|
<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">Nom</th>
|
||||||
<th class="px-4 py-3 text-left font-medium">Driver</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">Scope</th>
|
||||||
<th class="px-4 py-3 text-left font-medium">ID</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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#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)}
|
||||||
<tr class="border-b border-white/[0.04] last:border-0 hover:bg-white/[0.025] transition-colors">
|
<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>
|
{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 text-slate-400">{net.driver}</td>
|
||||||
<td class="px-4 py-3 text-xs">
|
<td class="px-4 py-3 text-xs">
|
||||||
<span class="px-1.5 py-0.5 rounded text-xs
|
<span class="px-1.5 py-0.5 rounded text-xs
|
||||||
@ -1370,14 +1706,59 @@
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 font-mono text-xs text-slate-600">{shortId(net.id)}</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>
|
</tr>
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</section>
|
</section>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user