fix: fix mobile view + add orphean deletions
This commit is contained in:
@ -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 {
|
||||
.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 {
|
||||
.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 {
|
||||
.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: n.name.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()
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,12 +40,14 @@ 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 {
|
||||
@ -53,6 +55,7 @@ message NetworkInfo {
|
||||
string name = 2;
|
||||
string driver = 3;
|
||||
string scope = 4;
|
||||
bool is_orphan = 5;
|
||||
}
|
||||
|
||||
message ContainerSnapshot {
|
||||
@ -163,6 +166,23 @@ 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;
|
||||
@ -174,6 +194,9 @@ message ServerMessage {
|
||||
CreateDirCommand create_dir = 7;
|
||||
CheckUpdateCommand check_update = 8;
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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"`
|
||||
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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,11 +950,13 @@
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
{@render ActionBtn({ label: "Logs", variant: "cyan", loading: false,
|
||||
onclick: () => openProjectLogs(agentId, projectName, projectContainers) })}
|
||||
|
||||
<!-- 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') })}
|
||||
<!-- Auto-update groupe -->
|
||||
<button
|
||||
data-group-autoupdate-btn
|
||||
onclick={(e) => { e.stopPropagation(); openGroupAutoUpdate(e, agentId, projectName, projectContainers); }}
|
||||
@ -867,6 +972,47 @@
|
||||
</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>
|
||||
|
||||
<!-- Desktop table du projet (collapsible) -->
|
||||
@ -1127,12 +1273,28 @@
|
||||
</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">
|
||||
|
||||
<div class="flex items-center gap-2.5 mb-3 px-1">
|
||||
<button
|
||||
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
|
||||
class="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
|
||||
onclick={() => toggleImages(agentId)}
|
||||
type="button"
|
||||
>
|
||||
@ -1148,56 +1310,119 @@
|
||||
{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">
|
||||
{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}
|
||||
</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">
|
||||
<div class="flex flex-wrap items-center gap-1">
|
||||
{#if img.tags?.length > 0}
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#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"><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>
|
||||
</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"><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>
|
||||
{/if}
|
||||
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
@ -1227,12 +1452,28 @@
|
||||
</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">
|
||||
|
||||
<div class="flex items-center gap-2.5 mb-3 px-1">
|
||||
<button
|
||||
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
|
||||
class="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
|
||||
onclick={() => toggleVolumes(agentId)}
|
||||
type="button"
|
||||
>
|
||||
@ -1248,42 +1489,102 @@
|
||||
{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">
|
||||
{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}
|
||||
</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,12 +1614,28 @@
|
||||
</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">
|
||||
|
||||
<div class="flex items-center gap-2.5 mb-3 px-1">
|
||||
<button
|
||||
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
|
||||
class="flex items-center gap-2.5 flex-1 min-w-0 text-left cursor-pointer group"
|
||||
onclick={() => toggleNetworks(agentId)}
|
||||
type="button"
|
||||
>
|
||||
@ -1334,32 +1651,51 @@
|
||||
{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">
|
||||
{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}
|
||||
</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}
|
||||
|
||||
Reference in New Issue
Block a user