feat: add volume, images and networks
This commit is contained in:
@ -4,12 +4,15 @@ use bollard::{
|
|||||||
ListContainersOptions, LogOutput, LogsOptions, RemoveContainerOptions,
|
ListContainersOptions, LogOutput, LogsOptions, RemoveContainerOptions,
|
||||||
StartContainerOptions, StopContainerOptions,
|
StartContainerOptions, StopContainerOptions,
|
||||||
},
|
},
|
||||||
|
image::ListImagesOptions,
|
||||||
|
network::ListNetworksOptions,
|
||||||
|
volume::ListVolumesOptions,
|
||||||
Docker,
|
Docker,
|
||||||
};
|
};
|
||||||
use futures_util::Stream;
|
use futures_util::Stream;
|
||||||
use std::{collections::HashMap, pin::Pin};
|
use std::{collections::HashMap, pin::Pin};
|
||||||
|
|
||||||
use crate::proto::{ContainerInfo, ContainerPort};
|
use crate::proto::{ContainerInfo, ContainerPort, ImageInfo, NetworkInfo, VolumeInfo};
|
||||||
|
|
||||||
// ── Public trait ─────────────────────────────────────────────────────────────
|
// ── Public trait ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@ -24,6 +27,10 @@ pub trait ContainerBackend: Clone + Send + Sync + 'static {
|
|||||||
fn restart(&self, id: &str) -> impl std::future::Future<Output = Result<()>> + Send;
|
fn restart(&self, id: &str) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||||
fn remove(&self, id: &str) -> impl std::future::Future<Output = Result<()>> + Send;
|
fn remove(&self, id: &str) -> impl std::future::Future<Output = Result<()>> + Send;
|
||||||
|
|
||||||
|
fn list_images(&self) -> impl std::future::Future<Output = Result<Vec<ImageInfo>>> + 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 logs(
|
fn logs(
|
||||||
&self,
|
&self,
|
||||||
id: &str,
|
id: &str,
|
||||||
@ -138,6 +145,55 @@ impl ContainerBackend for DockerClient {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_images(&self) -> Result<Vec<ImageInfo>> {
|
||||||
|
let images = self
|
||||||
|
.inner
|
||||||
|
.list_images(None::<ListImagesOptions<String>>)
|
||||||
|
.await?;
|
||||||
|
Ok(images
|
||||||
|
.into_iter()
|
||||||
|
.map(|c| ImageInfo {
|
||||||
|
id: c.id,
|
||||||
|
tags: c.repo_tags,
|
||||||
|
size: c.size,
|
||||||
|
created_at: c.created,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_volumes(&self) -> Result<Vec<VolumeInfo>> {
|
||||||
|
let info = self
|
||||||
|
.inner
|
||||||
|
.list_volumes(None::<ListVolumesOptions<String>>)
|
||||||
|
.await?;
|
||||||
|
Ok(info
|
||||||
|
.volumes
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(|v| VolumeInfo {
|
||||||
|
name: v.name,
|
||||||
|
driver: v.driver,
|
||||||
|
mountpoint: v.mountpoint,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_networks(&self) -> Result<Vec<NetworkInfo>> {
|
||||||
|
let networks = self
|
||||||
|
.inner
|
||||||
|
.list_networks(None::<ListNetworksOptions<String>>)
|
||||||
|
.await?;
|
||||||
|
Ok(networks
|
||||||
|
.into_iter()
|
||||||
|
.map(|n| NetworkInfo {
|
||||||
|
id: n.id.unwrap_or_default(),
|
||||||
|
name: n.name.unwrap_or_default(),
|
||||||
|
driver: n.driver.unwrap_or_default(),
|
||||||
|
scope: n.scope.unwrap_or_default(),
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
fn logs(
|
fn logs(
|
||||||
&self,
|
&self,
|
||||||
id: &str,
|
id: &str,
|
||||||
@ -230,6 +286,38 @@ pub mod tests {
|
|||||||
}])
|
}])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn list_images(&self) -> Result<Vec<ImageInfo>> {
|
||||||
|
self.record("list_images".to_string());
|
||||||
|
self.maybe_err()?;
|
||||||
|
Ok(vec![ImageInfo {
|
||||||
|
id: "sha256:abc".to_string(),
|
||||||
|
tags: vec!["nginx:latest".to_string()],
|
||||||
|
size: 1024,
|
||||||
|
created_at: 1_700_000_000,
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_volumes(&self) -> Result<Vec<VolumeInfo>> {
|
||||||
|
self.record("list_volumes".to_string());
|
||||||
|
self.maybe_err()?;
|
||||||
|
Ok(vec![VolumeInfo {
|
||||||
|
name: "data".to_string(),
|
||||||
|
driver: "local".to_string(),
|
||||||
|
mountpoint: "/var/lib/docker/volumes/data/_data".to_string(),
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_networks(&self) -> Result<Vec<NetworkInfo>> {
|
||||||
|
self.record("list_networks".to_string());
|
||||||
|
self.maybe_err()?;
|
||||||
|
Ok(vec![NetworkInfo {
|
||||||
|
id: "net123".to_string(),
|
||||||
|
name: "bridge".to_string(),
|
||||||
|
driver: "bridge".to_string(),
|
||||||
|
scope: "local".to_string(),
|
||||||
|
}])
|
||||||
|
}
|
||||||
|
|
||||||
async fn start(&self, id: &str) -> Result<()> {
|
async fn start(&self, id: &str) -> Result<()> {
|
||||||
self.record(format!("start:{id}"));
|
self.record(format!("start:{id}"));
|
||||||
self.maybe_err()
|
self.maybe_err()
|
||||||
@ -291,6 +379,81 @@ pub mod tests {
|
|||||||
assert!(err.to_string().contains("docker down"));
|
assert!(err.to_string().contains("docker down"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── list_images ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_list_images_returns_one_entry() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
let images = backend.list_images().await.unwrap();
|
||||||
|
assert_eq!(images.len(), 1);
|
||||||
|
assert_eq!(images[0].tags, vec!["nginx:latest"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_list_images_records_call() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
backend.list_images().await.unwrap();
|
||||||
|
let calls = backend.calls.lock().unwrap().clone();
|
||||||
|
assert_eq!(calls, vec!["list_images"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_list_images_propagates_error() {
|
||||||
|
let backend = MockBackend::failing("image daemon down");
|
||||||
|
let err = backend.list_images().await.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("image daemon down"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── list_volumes ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_list_volumes_returns_one_entry() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
let volumes = backend.list_volumes().await.unwrap();
|
||||||
|
assert_eq!(volumes.len(), 1);
|
||||||
|
assert_eq!(volumes[0].name, "data");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_list_volumes_records_call() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
backend.list_volumes().await.unwrap();
|
||||||
|
let calls = backend.calls.lock().unwrap().clone();
|
||||||
|
assert_eq!(calls, vec!["list_volumes"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_list_volumes_propagates_error() {
|
||||||
|
let backend = MockBackend::failing("volume daemon down");
|
||||||
|
let err = backend.list_volumes().await.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("volume daemon down"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── list_networks ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_list_networks_returns_one_entry() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
let networks = backend.list_networks().await.unwrap();
|
||||||
|
assert_eq!(networks.len(), 1);
|
||||||
|
assert_eq!(networks[0].name, "bridge");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_list_networks_records_call() {
|
||||||
|
let backend = MockBackend::new();
|
||||||
|
backend.list_networks().await.unwrap();
|
||||||
|
let calls = backend.calls.lock().unwrap().clone();
|
||||||
|
assert_eq!(calls, vec!["list_networks"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn mock_list_networks_propagates_error() {
|
||||||
|
let backend = MockBackend::failing("network daemon down");
|
||||||
|
let err = backend.list_networks().await.unwrap_err();
|
||||||
|
assert!(err.to_string().contains("network daemon down"));
|
||||||
|
}
|
||||||
|
|
||||||
// ── start / stop / restart / remove ──────────────────────────────────────
|
// ── start / stop / restart / remove ──────────────────────────────────────
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@ -12,6 +12,7 @@ use proto::{
|
|||||||
agent_gateway_client::AgentGatewayClient,
|
agent_gateway_client::AgentGatewayClient,
|
||||||
agent_message, server_message,
|
agent_message, server_message,
|
||||||
AgentHandshake, AgentMessage, ContainerAction, ContainerSnapshot,
|
AgentHandshake, AgentMessage, ContainerAction, ContainerSnapshot,
|
||||||
|
ImageInfo, VolumeInfo, NetworkInfo,
|
||||||
};
|
};
|
||||||
use std::{collections::HashMap, env, time::Duration};
|
use std::{collections::HashMap, env, time::Duration};
|
||||||
use tokio::{sync::mpsc, task::JoinHandle, time};
|
use tokio::{sync::mpsc, task::JoinHandle, time};
|
||||||
@ -73,22 +74,47 @@ async fn run(url: &str, token: &str, hostname: &str, docker: DockerClient) -> Re
|
|||||||
loop {
|
loop {
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
_ = snapshot_ticker.tick() => {
|
_ = snapshot_ticker.tick() => {
|
||||||
match time::timeout(Duration::from_secs(5), docker.list_containers()).await {
|
let (containers_res, images_res, volumes_res, networks_res) = tokio::join!(
|
||||||
Err(_) => warn!("docker list timed out"),
|
time::timeout(Duration::from_secs(5), docker.list_containers()),
|
||||||
Ok(Err(e)) => warn!("docker list failed: {:#}", e),
|
time::timeout(Duration::from_secs(5), docker.list_images()),
|
||||||
Ok(Ok(containers)) => {
|
time::timeout(Duration::from_secs(5), docker.list_volumes()),
|
||||||
|
time::timeout(Duration::from_secs(5), docker.list_networks()),
|
||||||
|
);
|
||||||
|
|
||||||
|
let containers = match containers_res {
|
||||||
|
Err(_) => { warn!("docker list_containers timed out"); continue; }
|
||||||
|
Ok(Err(e)) => { warn!("docker list_containers failed: {:#}", e); continue; }
|
||||||
|
Ok(Ok(v)) => v,
|
||||||
|
};
|
||||||
|
let images: Vec<ImageInfo> = match images_res {
|
||||||
|
Err(_) => { warn!("docker list_images timed out"); vec![] }
|
||||||
|
Ok(Err(e)) => { warn!("docker list_images failed: {:#}", e); vec![] }
|
||||||
|
Ok(Ok(v)) => v,
|
||||||
|
};
|
||||||
|
let volumes: Vec<VolumeInfo> = match volumes_res {
|
||||||
|
Err(_) => { warn!("docker list_volumes timed out"); vec![] }
|
||||||
|
Ok(Err(e)) => { warn!("docker list_volumes failed: {:#}", e); vec![] }
|
||||||
|
Ok(Ok(v)) => v,
|
||||||
|
};
|
||||||
|
let networks: Vec<NetworkInfo> = match networks_res {
|
||||||
|
Err(_) => { warn!("docker list_networks timed out"); vec![] }
|
||||||
|
Ok(Err(e)) => { warn!("docker list_networks failed: {:#}", e); vec![] }
|
||||||
|
Ok(Ok(v)) => v,
|
||||||
|
};
|
||||||
|
|
||||||
let msg = AgentMessage {
|
let msg = AgentMessage {
|
||||||
payload: Some(agent_message::Payload::Snapshot(ContainerSnapshot {
|
payload: Some(agent_message::Payload::Snapshot(ContainerSnapshot {
|
||||||
containers,
|
containers,
|
||||||
timestamp: unix_now(),
|
timestamp: unix_now(),
|
||||||
|
images,
|
||||||
|
volumes,
|
||||||
|
networks,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
if tx.send(msg).await.is_err() {
|
if tx.send(msg).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = inbound.message() => {
|
result = inbound.message() => {
|
||||||
match result? {
|
match result? {
|
||||||
|
|||||||
@ -34,9 +34,32 @@ message AgentHandshake {
|
|||||||
string os = 4;
|
string os = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ImageInfo {
|
||||||
|
string id = 1;
|
||||||
|
repeated string tags = 2;
|
||||||
|
int64 size = 3;
|
||||||
|
int64 created_at = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message VolumeInfo {
|
||||||
|
string name = 1;
|
||||||
|
string driver = 2;
|
||||||
|
string mountpoint = 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
message NetworkInfo {
|
||||||
|
string id = 1;
|
||||||
|
string name = 2;
|
||||||
|
string driver = 3;
|
||||||
|
string scope = 4;
|
||||||
|
}
|
||||||
|
|
||||||
message ContainerSnapshot {
|
message ContainerSnapshot {
|
||||||
repeated ContainerInfo containers = 1;
|
repeated ContainerInfo containers = 1;
|
||||||
int64 timestamp = 2;
|
int64 timestamp = 2;
|
||||||
|
repeated ImageInfo images = 3;
|
||||||
|
repeated VolumeInfo volumes = 4;
|
||||||
|
repeated NetworkInfo networks = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
message CommandResult {
|
message CommandResult {
|
||||||
|
|||||||
@ -559,6 +559,188 @@ func TestDeleteAgent_NonExistent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── ListImages ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestListImages_Empty(t *testing.T) {
|
||||||
|
h, _, _, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/images", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ListImages(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []map[string]any
|
||||||
|
json.NewDecoder(w.Body).Decode(&out)
|
||||||
|
if len(out) != 0 {
|
||||||
|
t.Errorf("expected empty list, got %d", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListImages_WithData(t *testing.T) {
|
||||||
|
h, _, reg, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
|
||||||
|
reg.UpdateResources("a1",
|
||||||
|
nil,
|
||||||
|
[]*agentv1.ImageInfo{
|
||||||
|
{Id: "sha256:abc", Tags: []string{"nginx:latest"}, Size: 50000000, CreatedAt: 1700000000},
|
||||||
|
{Id: "sha256:def", Tags: []string{"redis:7"}, Size: 30000000, CreatedAt: 1700000001},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/images", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ListImages(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(w.Body).Decode(&out)
|
||||||
|
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("expected 2 images, got %d", len(out))
|
||||||
|
}
|
||||||
|
if out[0].AgentID != "a1" {
|
||||||
|
t.Errorf("expected agent_id 'a1', got %q", out[0].AgentID)
|
||||||
|
}
|
||||||
|
if out[0].ID != "sha256:abc" && out[1].ID != "sha256:abc" {
|
||||||
|
t.Error("expected sha256:abc in results")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ListVolumes ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestListVolumes_Empty(t *testing.T) {
|
||||||
|
h, _, _, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/volumes", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ListVolumes(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []map[string]any
|
||||||
|
json.NewDecoder(w.Body).Decode(&out)
|
||||||
|
if len(out) != 0 {
|
||||||
|
t.Errorf("expected empty list, got %d", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListVolumes_WithData(t *testing.T) {
|
||||||
|
h, _, reg, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
|
||||||
|
reg.UpdateResources("a1",
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
[]*agentv1.VolumeInfo{
|
||||||
|
{Name: "data", Driver: "local", Mountpoint: "/var/lib/docker/volumes/data/_data"},
|
||||||
|
{Name: "cache", Driver: "local", Mountpoint: "/var/lib/docker/volumes/cache/_data"},
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/volumes", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ListVolumes(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Driver string `json:"driver"`
|
||||||
|
Mountpoint string `json:"mountpoint"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(w.Body).Decode(&out)
|
||||||
|
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("expected 2 volumes, got %d", len(out))
|
||||||
|
}
|
||||||
|
names := map[string]bool{out[0].Name: true, out[1].Name: true}
|
||||||
|
if !names["data"] || !names["cache"] {
|
||||||
|
t.Errorf("expected volumes 'data' and 'cache', got %v", names)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ListNetworks ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestListNetworks_Empty(t *testing.T) {
|
||||||
|
h, _, _, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/networks", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ListNetworks(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []map[string]any
|
||||||
|
json.NewDecoder(w.Body).Decode(&out)
|
||||||
|
if len(out) != 0 {
|
||||||
|
t.Errorf("expected empty list, got %d", len(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListNetworks_WithData(t *testing.T) {
|
||||||
|
h, _, reg, _ := newTestHandler(t)
|
||||||
|
|
||||||
|
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
|
||||||
|
reg.UpdateResources("a1",
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
nil,
|
||||||
|
[]*agentv1.NetworkInfo{
|
||||||
|
{Id: "net1", Name: "bridge", Driver: "bridge", Scope: "local"},
|
||||||
|
{Id: "net2", Name: "host", Driver: "host", Scope: "local"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/networks", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ListNetworks(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var out []struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Driver string `json:"driver"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
}
|
||||||
|
json.NewDecoder(w.Body).Decode(&out)
|
||||||
|
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("expected 2 networks, got %d", len(out))
|
||||||
|
}
|
||||||
|
ids := map[string]bool{out[0].ID: true, out[1].ID: true}
|
||||||
|
if !ids["net1"] || !ids["net2"] {
|
||||||
|
t.Errorf("expected net1 and net2, got %v", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── ContainerAction ───────────────────────────────────────────────────────────
|
// ── ContainerAction ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func TestContainerAction_AgentNotConnected(t *testing.T) {
|
func TestContainerAction_AgentNotConnected(t *testing.T) {
|
||||||
|
|||||||
@ -178,6 +178,91 @@ func (h *Handler) ContainerAction(w http.ResponseWriter, r *http.Request) {
|
|||||||
jsonOK(w, map[string]string{"command_id": cmdID})
|
jsonOK(w, map[string]string{"command_id": cmdID})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Images ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type imageDTO struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
var out []imageDTO
|
||||||
|
for _, agent := range h.registry.List() {
|
||||||
|
for _, img := range agent.Images {
|
||||||
|
out = append(out, imageDTO{
|
||||||
|
AgentID: agent.ID,
|
||||||
|
Hostname: agent.Hostname,
|
||||||
|
Alias: agent.Alias,
|
||||||
|
ID: img.GetId(),
|
||||||
|
Tags: img.GetTags(),
|
||||||
|
Size: img.GetSize(),
|
||||||
|
CreatedAt: img.GetCreatedAt(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonOK(w, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Volumes ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type volumeDTO struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Driver string `json:"driver"`
|
||||||
|
Mountpoint string `json:"mountpoint"`
|
||||||
|
}
|
||||||
|
var out []volumeDTO
|
||||||
|
for _, agent := range h.registry.List() {
|
||||||
|
for _, vol := range agent.Volumes {
|
||||||
|
out = append(out, volumeDTO{
|
||||||
|
AgentID: agent.ID,
|
||||||
|
Hostname: agent.Hostname,
|
||||||
|
Alias: agent.Alias,
|
||||||
|
Name: vol.GetName(),
|
||||||
|
Driver: vol.GetDriver(),
|
||||||
|
Mountpoint: vol.GetMountpoint(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonOK(w, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Networks ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
|
||||||
|
type networkDTO struct {
|
||||||
|
AgentID string `json:"agent_id"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Driver string `json:"driver"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
}
|
||||||
|
var out []networkDTO
|
||||||
|
for _, agent := range h.registry.List() {
|
||||||
|
for _, net := range agent.Networks {
|
||||||
|
out = append(out, networkDTO{
|
||||||
|
AgentID: agent.ID,
|
||||||
|
Hostname: agent.Hostname,
|
||||||
|
Alias: agent.Alias,
|
||||||
|
ID: net.GetId(),
|
||||||
|
Name: net.GetName(),
|
||||||
|
Driver: net.GetDriver(),
|
||||||
|
Scope: net.GetScope(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jsonOK(w, out)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Agent token provisioning ──────────────────────────────────────────────────
|
// ── Agent token provisioning ──────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *Handler) CreateAgentToken(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) CreateAgentToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@ -46,6 +46,9 @@ func NewRouter(h *Handler) http.Handler {
|
|||||||
r.Patch("/agents/{agentID}", h.UpdateAgent)
|
r.Patch("/agents/{agentID}", h.UpdateAgent)
|
||||||
r.Delete("/agents/{agentID}", h.DeleteAgent)
|
r.Delete("/agents/{agentID}", h.DeleteAgent)
|
||||||
r.Get("/containers", h.ListContainers)
|
r.Get("/containers", h.ListContainers)
|
||||||
|
r.Get("/images", h.ListImages)
|
||||||
|
r.Get("/volumes", h.ListVolumes)
|
||||||
|
r.Get("/networks", h.ListNetworks)
|
||||||
r.Post("/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
|
r.Post("/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
|
||||||
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)
|
||||||
|
|||||||
@ -108,12 +108,21 @@ func (g *Gateway) Tunnel(stream agentv1.AgentGateway_TunnelServer) error {
|
|||||||
|
|
||||||
switch p := msg.Payload.(type) {
|
switch p := msg.Payload.(type) {
|
||||||
case *agentv1.AgentMessage_Snapshot:
|
case *agentv1.AgentMessage_Snapshot:
|
||||||
g.registry.UpdateContainers(agentID, p.Snapshot.Containers)
|
g.registry.UpdateResources(agentID, p.Snapshot.Containers, p.Snapshot.Images, p.Snapshot.Volumes, p.Snapshot.Networks)
|
||||||
g.broker.Publish(broker.Event{
|
g.broker.Publish(broker.Event{
|
||||||
Type: "containers.updated",
|
Type: "containers.updated",
|
||||||
AgentID: agentID,
|
AgentID: agentID,
|
||||||
Payload: p.Snapshot.Containers,
|
Payload: p.Snapshot.Containers,
|
||||||
})
|
})
|
||||||
|
g.broker.Publish(broker.Event{
|
||||||
|
Type: "resources.updated",
|
||||||
|
AgentID: agentID,
|
||||||
|
Payload: map[string]any{
|
||||||
|
"images": p.Snapshot.Images,
|
||||||
|
"volumes": p.Snapshot.Volumes,
|
||||||
|
"networks": p.Snapshot.Networks,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
case *agentv1.AgentMessage_Result:
|
case *agentv1.AgentMessage_Result:
|
||||||
g.broker.Publish(broker.Event{
|
g.broker.Publish(broker.Event{
|
||||||
|
|||||||
@ -16,6 +16,9 @@ type AgentState struct {
|
|||||||
OS string
|
OS string
|
||||||
LastSeenAt time.Time
|
LastSeenAt time.Time
|
||||||
Containers []*agentv1.ContainerInfo
|
Containers []*agentv1.ContainerInfo
|
||||||
|
Images []*agentv1.ImageInfo
|
||||||
|
Volumes []*agentv1.VolumeInfo
|
||||||
|
Networks []*agentv1.NetworkInfo
|
||||||
|
|
||||||
cmdCh chan *agentv1.ServerMessage
|
cmdCh chan *agentv1.ServerMessage
|
||||||
}
|
}
|
||||||
@ -80,6 +83,18 @@ func (r *Registry) UpdateContainers(id string, containers []*agentv1.ContainerIn
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Registry) UpdateResources(id string, containers []*agentv1.ContainerInfo, images []*agentv1.ImageInfo, volumes []*agentv1.VolumeInfo, networks []*agentv1.NetworkInfo) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if s, ok := r.agents[id]; ok {
|
||||||
|
s.Containers = containers
|
||||||
|
s.Images = images
|
||||||
|
s.Volumes = volumes
|
||||||
|
s.Networks = networks
|
||||||
|
s.LastSeenAt = time.Now()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateAlias refreshes the alias for a live agent (called after an admin update).
|
// UpdateAlias refreshes the alias for a live agent (called after an admin update).
|
||||||
func (r *Registry) UpdateAlias(id, alias string) {
|
func (r *Registry) UpdateAlias(id, alias string) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
|||||||
@ -118,6 +118,71 @@ export async function containerAction(
|
|||||||
return r.json();
|
return r.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ImageEntry {
|
||||||
|
agent_id: string;
|
||||||
|
hostname: string;
|
||||||
|
alias: string;
|
||||||
|
id: string;
|
||||||
|
tags: string[];
|
||||||
|
size: number;
|
||||||
|
created_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VolumeEntry {
|
||||||
|
agent_id: string;
|
||||||
|
hostname: string;
|
||||||
|
alias: string;
|
||||||
|
name: string;
|
||||||
|
driver: string;
|
||||||
|
mountpoint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkEntry {
|
||||||
|
agent_id: string;
|
||||||
|
hostname: string;
|
||||||
|
alias: string;
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
driver: string;
|
||||||
|
scope: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchImages(): Promise<ImageEntry[]> {
|
||||||
|
const ac = new AbortController();
|
||||||
|
const t = setTimeout(() => ac.abort(), 8000);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch(`${BASE}/images`, { signal: ac.signal });
|
||||||
|
if (!r.ok) throw new Error(`images: ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchVolumes(): Promise<VolumeEntry[]> {
|
||||||
|
const ac = new AbortController();
|
||||||
|
const t = setTimeout(() => ac.abort(), 8000);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch(`${BASE}/volumes`, { signal: ac.signal });
|
||||||
|
if (!r.ok) throw new Error(`volumes: ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchNetworks(): Promise<NetworkEntry[]> {
|
||||||
|
const ac = new AbortController();
|
||||||
|
const t = setTimeout(() => ac.abort(), 8000);
|
||||||
|
try {
|
||||||
|
const r = await apiFetch(`${BASE}/networks`, { signal: ac.signal });
|
||||||
|
if (!r.ok) throw new Error(`networks: ${r.status}`);
|
||||||
|
return r.json();
|
||||||
|
} finally {
|
||||||
|
clearTimeout(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function connectLogs(
|
export function connectLogs(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
containerId: string,
|
containerId: string,
|
||||||
|
|||||||
@ -8,14 +8,21 @@
|
|||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import {
|
import {
|
||||||
fetchContainers,
|
fetchContainers,
|
||||||
|
fetchImages,
|
||||||
|
fetchVolumes,
|
||||||
|
fetchNetworks,
|
||||||
containerAction,
|
containerAction,
|
||||||
connectEvents,
|
connectEvents,
|
||||||
type ContainerEntry,
|
type ContainerEntry,
|
||||||
type ContainerPort,
|
type ContainerPort,
|
||||||
|
type ImageEntry,
|
||||||
|
type VolumeEntry,
|
||||||
|
type NetworkEntry,
|
||||||
} from "$lib/api";
|
} from "$lib/api";
|
||||||
import { clearToken } from "$lib/auth";
|
import { clearToken } from "$lib/auth";
|
||||||
import LogModal from "$lib/LogModal.svelte";
|
import LogModal from "$lib/LogModal.svelte";
|
||||||
|
|
||||||
|
// ── Logs modal ────────────────────────────────────────────────────────────
|
||||||
let logTarget = $state<{ agentId: string; containerId: string; name: string } | null>(null);
|
let logTarget = $state<{ agentId: string; containerId: string; name: string } | null>(null);
|
||||||
|
|
||||||
function openLogs(agentId: string, containerId: string, name: string) {
|
function openLogs(agentId: string, containerId: string, name: string) {
|
||||||
@ -27,11 +34,26 @@
|
|||||||
goto("/login");
|
goto("/login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tab state ─────────────────────────────────────────────────────────────
|
||||||
|
type Tab = "containers" | "images" | "volumes" | "networks";
|
||||||
|
let activeTab = $state<Tab>("containers");
|
||||||
|
|
||||||
|
// ── Data ──────────────────────────────────────────────────────────────────
|
||||||
let entries = $state<ContainerEntry[] | null>(null);
|
let entries = $state<ContainerEntry[] | null>(null);
|
||||||
|
let images = $state<ImageEntry[] | null>(null);
|
||||||
|
let volumes = $state<VolumeEntry[] | null>(null);
|
||||||
|
let networks = $state<NetworkEntry[] | null>(null);
|
||||||
let loadError = $state<string | null>(null);
|
let loadError = $state<string | null>(null);
|
||||||
let actionPending = $state<string | null>(null);
|
let actionPending = $state<string | null>(null);
|
||||||
let toast = $state<{ msg: string; ok: boolean } | null>(null);
|
let toast = $state<{ msg: string; ok: boolean } | null>(null);
|
||||||
|
|
||||||
|
// ── Collapsed states (independent per view) ───────────────────────────────
|
||||||
|
let collapsed = $state<Record<string, boolean>>({});
|
||||||
|
let collapsedImages = $state<Record<string, boolean>>({});
|
||||||
|
let collapsedVolumes = $state<Record<string, boolean>>({});
|
||||||
|
let collapsedNetworks= $state<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
// ── Derived: containers grouped by agent ─────────────────────────────────
|
||||||
const byAgent = $derived(
|
const byAgent = $derived(
|
||||||
(entries ?? []).reduce<Record<string, ContainerEntry[]>>((acc, e) => {
|
(entries ?? []).reduce<Record<string, ContainerEntry[]>>((acc, e) => {
|
||||||
(acc[e.agent_id] ??= []).push(e);
|
(acc[e.agent_id] ??= []).push(e);
|
||||||
@ -39,7 +61,6 @@
|
|||||||
}, {})
|
}, {})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Tri alphabétique des agents : alias (si défini) sinon hostname, insensible à la casse
|
|
||||||
const sortedAgents = $derived(
|
const sortedAgents = $derived(
|
||||||
Object.entries(byAgent).sort(([, a], [, b]) => {
|
Object.entries(byAgent).sort(([, a], [, b]) => {
|
||||||
const labelA = (a[0]?.alias || a[0]?.hostname || "").toLowerCase();
|
const labelA = (a[0]?.alias || a[0]?.hostname || "").toLowerCase();
|
||||||
@ -48,23 +69,55 @@
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// État replié/déplié par agent (déplié par défaut)
|
// ── Derived: images grouped by agent ─────────────────────────────────────
|
||||||
let collapsed = $state<Record<string, boolean>>({});
|
const byAgentImages = $derived(
|
||||||
|
(images ?? []).reduce<Record<string, ImageEntry[]>>((acc, e) => {
|
||||||
|
(acc[e.agent_id] ??= []).push(e);
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
function toggleSection(agentId: string) {
|
const sortedAgentImages = $derived(
|
||||||
collapsed[agentId] = !collapsed[agentId];
|
Object.entries(byAgentImages).sort(([, a], [, b]) => {
|
||||||
}
|
const labelA = (a[0]?.alias || a[0]?.hostname || "").toLowerCase();
|
||||||
|
const labelB = (b[0]?.alias || b[0]?.hostname || "").toLowerCase();
|
||||||
|
return labelA.localeCompare(labelB);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
// Pastille de statut pour un agent selon l'état de ses containers
|
// ── Derived: volumes grouped by agent ────────────────────────────────────
|
||||||
function agentDotClass(containers: ContainerEntry[]): string {
|
const byAgentVolumes = $derived(
|
||||||
if (containers.length === 0) return "dot-offline";
|
(volumes ?? []).reduce<Record<string, VolumeEntry[]>>((acc, e) => {
|
||||||
const running = containers.filter(c => c.container.state === "running").length;
|
(acc[e.agent_id] ??= []).push(e);
|
||||||
if (running === containers.length) return "dot-running";
|
return acc;
|
||||||
if (running === 0) return "dot-exited";
|
}, {})
|
||||||
return "dot-other";
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// PWA install prompt
|
const sortedAgentVolumes = $derived(
|
||||||
|
Object.entries(byAgentVolumes).sort(([, a], [, b]) => {
|
||||||
|
const labelA = (a[0]?.alias || a[0]?.hostname || "").toLowerCase();
|
||||||
|
const labelB = (b[0]?.alias || b[0]?.hostname || "").toLowerCase();
|
||||||
|
return labelA.localeCompare(labelB);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Derived: networks grouped by agent ───────────────────────────────────
|
||||||
|
const byAgentNetworks = $derived(
|
||||||
|
(networks ?? []).reduce<Record<string, NetworkEntry[]>>((acc, e) => {
|
||||||
|
(acc[e.agent_id] ??= []).push(e);
|
||||||
|
return acc;
|
||||||
|
}, {})
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedAgentNetworks = $derived(
|
||||||
|
Object.entries(byAgentNetworks).sort(([, a], [, b]) => {
|
||||||
|
const labelA = (a[0]?.alias || a[0]?.hostname || "").toLowerCase();
|
||||||
|
const labelB = (b[0]?.alias || b[0]?.hostname || "").toLowerCase();
|
||||||
|
return labelA.localeCompare(labelB);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── PWA install prompt ────────────────────────────────────────────────────
|
||||||
let installPrompt = $state<BeforeInstallPromptEvent | null>(null);
|
let installPrompt = $state<BeforeInstallPromptEvent | null>(null);
|
||||||
|
|
||||||
function onBeforeInstallPrompt(e: Event) {
|
function onBeforeInstallPrompt(e: Event) {
|
||||||
@ -83,18 +136,61 @@
|
|||||||
installPrompt = null;
|
installPrompt = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── WebSocket ─────────────────────────────────────────────────────────────
|
||||||
let disconnect: (() => void) | null = null;
|
let disconnect: (() => void) | null = null;
|
||||||
|
|
||||||
|
// ── Load functions ────────────────────────────────────────────────────────
|
||||||
async function load() {
|
async function load() {
|
||||||
loadError = null;
|
loadError = null;
|
||||||
try {
|
try {
|
||||||
entries = await fetchContainers() ?? [];
|
entries = await fetchContainers() ?? [];
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
loadError = e.message;
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
entries = [];
|
entries = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadImages() {
|
||||||
|
try {
|
||||||
|
images = await fetchImages() ?? [];
|
||||||
|
} catch (e: unknown) {
|
||||||
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
images = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVolumes() {
|
||||||
|
try {
|
||||||
|
volumes = await fetchVolumes() ?? [];
|
||||||
|
} catch (e: unknown) {
|
||||||
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
volumes = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNetworks() {
|
||||||
|
try {
|
||||||
|
networks = await fetchNetworks() ?? [];
|
||||||
|
} catch (e: unknown) {
|
||||||
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
networks = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActiveTab() {
|
||||||
|
if (activeTab === "containers") await load();
|
||||||
|
else if (activeTab === "images") await loadImages();
|
||||||
|
else if (activeTab === "volumes") await loadVolumes();
|
||||||
|
else if (activeTab === "networks") await loadNetworks();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function switchTab(tab: Tab) {
|
||||||
|
activeTab = tab;
|
||||||
|
loadError = null;
|
||||||
|
await loadActiveTab();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Container actions ─────────────────────────────────────────────────────
|
||||||
async function doAction(
|
async function doAction(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
containerId: string,
|
containerId: string,
|
||||||
@ -105,8 +201,8 @@
|
|||||||
await containerAction(agentId, containerId, action);
|
await containerAction(agentId, containerId, action);
|
||||||
showToast(`${action} envoyé`, true);
|
showToast(`${action} envoyé`, true);
|
||||||
setTimeout(load, 1500);
|
setTimeout(load, 1500);
|
||||||
} catch (e: any) {
|
} catch (e: unknown) {
|
||||||
showToast(e.message, false);
|
showToast(e instanceof Error ? e.message : String(e), false);
|
||||||
} finally {
|
} finally {
|
||||||
actionPending = null;
|
actionPending = null;
|
||||||
}
|
}
|
||||||
@ -117,13 +213,25 @@
|
|||||||
setTimeout(() => (toast = null), 3000);
|
setTimeout(() => (toast = null), 3000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Toggle helpers ────────────────────────────────────────────────────────
|
||||||
|
function toggleSection(agentId: string) { collapsed[agentId] = !(collapsed[agentId] ?? true); }
|
||||||
|
function toggleImages(agentId: string) { collapsedImages[agentId] = !(collapsedImages[agentId] ?? true); }
|
||||||
|
function toggleVolumes(agentId: string) { collapsedVolumes[agentId] = !(collapsedVolumes[agentId] ?? true); }
|
||||||
|
function toggleNetworks(agentId: string) { collapsedNetworks[agentId] = !(collapsedNetworks[agentId] ?? true); }
|
||||||
|
|
||||||
|
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
load();
|
load();
|
||||||
disconnect = connectEvents((evt) => {
|
disconnect = connectEvents((evt) => {
|
||||||
if (evt.type === "containers.updated") load();
|
if (evt.type === "containers.updated" || evt.type === "agent.connected" || evt.type === "agent.disconnected") {
|
||||||
if (evt.type === "agent.connected" || evt.type === "agent.disconnected") load();
|
if (activeTab === "containers") load();
|
||||||
|
}
|
||||||
|
if (evt.type === "resources.updated") {
|
||||||
|
if (activeTab === "images") loadImages();
|
||||||
|
if (activeTab === "volumes") loadVolumes();
|
||||||
|
if (activeTab === "networks") loadNetworks();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// Récupère le prompt capturé tôt dans app.html avant que onMount soit prêt
|
|
||||||
if ((window as any).__installPrompt) {
|
if ((window as any).__installPrompt) {
|
||||||
installPrompt = (window as any).__installPrompt;
|
installPrompt = (window as any).__installPrompt;
|
||||||
(window as any).__installPrompt = null;
|
(window as any).__installPrompt = null;
|
||||||
@ -138,6 +246,7 @@
|
|||||||
window.removeEventListener("appinstalled", onAppInstalled);
|
window.removeEventListener("appinstalled", onAppInstalled);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
function uniquePorts(ports: ContainerPort[] | null) {
|
function uniquePorts(ports: ContainerPort[] | null) {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return (ports ?? []).filter(p => {
|
return (ports ?? []).filter(p => {
|
||||||
@ -160,6 +269,30 @@
|
|||||||
if (state === "exited") return "text-signal-red";
|
if (state === "exited") return "text-signal-red";
|
||||||
return "text-signal-yellow";
|
return "text-signal-yellow";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function agentDotClass(containers: ContainerEntry[]): string {
|
||||||
|
if (containers.length === 0) return "dot-offline";
|
||||||
|
const running = containers.filter(c => c.container.state === "running").length;
|
||||||
|
if (running === containers.length) return "dot-running";
|
||||||
|
if (running === 0) return "dot-exited";
|
||||||
|
return "dot-other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`;
|
||||||
|
if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(ts: number): string {
|
||||||
|
return new Date(ts * 1000).toLocaleDateString("fr-FR");
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortId(id: string): string {
|
||||||
|
// sha256:abc... → take 12 chars after the colon (or from start)
|
||||||
|
const bare = id.startsWith("sha256:") ? id.slice(7) : id;
|
||||||
|
return bare.slice(0, 12);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@ -197,10 +330,22 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ml-auto flex items-center gap-1">
|
<div class="ml-auto flex items-center gap-1">
|
||||||
{#if entries !== null}
|
{#if activeTab === "containers" && entries !== null}
|
||||||
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
<span class="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" && images !== null}
|
||||||
|
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
||||||
|
{images.length} images · {Object.keys(byAgentImages).length} hosts
|
||||||
|
</span>
|
||||||
|
{:else if activeTab === "volumes" && volumes !== null}
|
||||||
|
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
||||||
|
{volumes.length} volumes · {Object.keys(byAgentVolumes).length} hosts
|
||||||
|
</span>
|
||||||
|
{:else if activeTab === "networks" && networks !== null}
|
||||||
|
<span class="text-xs text-slate-500 mr-3 tabular-nums">
|
||||||
|
{networks.length} networks · {Object.keys(byAgentNetworks).length} hosts
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if installPrompt}
|
{#if installPrompt}
|
||||||
@ -220,7 +365,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button onclick={load} class="nav-btn" title="Actualiser">
|
<button onclick={loadActiveTab} class="nav-btn" title="Actualiser">
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.75"
|
||||||
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" />
|
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" />
|
||||||
@ -236,8 +381,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- 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">
|
||||||
|
{#each ([
|
||||||
|
{ id: "containers", label: "Containers" },
|
||||||
|
{ id: "images", label: "Images" },
|
||||||
|
{ id: "volumes", label: "Volumes" },
|
||||||
|
{ id: "networks", label: "Networks" },
|
||||||
|
] 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
|
||||||
|
{activeTab === tab.id
|
||||||
|
? 'border-cyan-400 text-cyan-400'
|
||||||
|
: 'border-transparent text-slate-400 hover:text-slate-200 hover:border-slate-600'}"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main class="p-4 md:p-6 max-w-7xl mx-auto">
|
<main class="p-4 md:p-6 max-w-7xl mx-auto">
|
||||||
|
|
||||||
|
{#key activeTab}
|
||||||
|
<!-- ═══════════════════════════════════════════════════
|
||||||
|
CONTAINERS TAB
|
||||||
|
════════════════════════════════════════════════════ -->
|
||||||
|
{#if activeTab === "containers"}
|
||||||
{#if entries === null}
|
{#if entries === null}
|
||||||
<div class="flex flex-col items-center justify-center h-64 gap-3 text-slate-600">
|
<div class="flex flex-col items-center justify-center h-64 gap-3 text-slate-600">
|
||||||
<div class="w-8 h-8 border-2 border-emerald/30 border-t-emerald-bright rounded-full animate-spin"></div>
|
<div class="w-8 h-8 border-2 border-emerald/30 border-t-emerald-bright rounded-full animate-spin"></div>
|
||||||
@ -253,8 +425,7 @@
|
|||||||
{:else if Object.keys(byAgent).length === 0}
|
{:else if Object.keys(byAgent).length === 0}
|
||||||
<div class="flex flex-col items-center justify-center h-64 gap-2 text-slate-600">
|
<div class="flex flex-col items-center justify-center h-64 gap-2 text-slate-600">
|
||||||
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M5 12h14M12 5l7 7-7 7"/>
|
||||||
d="M5 12h14M12 5l7 7-7 7"/>
|
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-sm">Aucun agent connecté</span>
|
<span class="text-sm">Aucun agent connecté</span>
|
||||||
</div>
|
</div>
|
||||||
@ -263,16 +434,14 @@
|
|||||||
{#each sortedAgents as [agentId, containers]}
|
{#each sortedAgents as [agentId, containers]}
|
||||||
{#if containers.length > 0}
|
{#if containers.length > 0}
|
||||||
{@const first = containers[0]}
|
{@const first = containers[0]}
|
||||||
{@const isCollapsed = collapsed[agentId] ?? false}
|
{@const isCollapsed = collapsed[agentId] ?? true}
|
||||||
<section class="mb-8">
|
<section class="mb-8">
|
||||||
|
|
||||||
<!-- Host header (cliquable pour replier/déplier) -->
|
|
||||||
<button
|
<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 mb-3 px-1 w-full text-left cursor-pointer group"
|
||||||
onclick={() => toggleSection(agentId)}
|
onclick={() => toggleSection(agentId)}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
<!-- Chevron -->
|
|
||||||
<svg
|
<svg
|
||||||
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
|
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
|
||||||
{isCollapsed ? '-rotate-90' : 'rotate-0'}"
|
{isCollapsed ? '-rotate-90' : 'rotate-0'}"
|
||||||
@ -280,7 +449,6 @@
|
|||||||
>
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
|
||||||
</svg>
|
</svg>
|
||||||
<!-- Pastille de statut dynamique -->
|
|
||||||
<span class={agentDotClass(containers)}></span>
|
<span class={agentDotClass(containers)}></span>
|
||||||
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
|
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
|
||||||
{first.alias || first.hostname}
|
{first.alias || first.hostname}
|
||||||
@ -412,6 +580,280 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════
|
||||||
|
IMAGES TAB
|
||||||
|
════════════════════════════════════════════════════ -->
|
||||||
|
{:else if activeTab === "images"}
|
||||||
|
{#if images === null}
|
||||||
|
<div class="flex flex-col items-center justify-center h-64 gap-3 text-slate-600">
|
||||||
|
<div class="w-8 h-8 border-2 border-cyan-400/30 border-t-cyan-400 rounded-full animate-spin"></div>
|
||||||
|
<span class="text-sm">Chargement…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if loadError}
|
||||||
|
<div class="flex items-center gap-3 max-w-md mx-auto mt-16 p-4 card border-signal-red/20">
|
||||||
|
<span class="text-signal-red text-xl">⚠</span>
|
||||||
|
<p class="text-signal-red text-sm">{loadError}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if sortedAgentImages.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center h-64 gap-2 text-slate-600">
|
||||||
|
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm">Aucune image disponible</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
{#each sortedAgentImages as [agentId, agentImages]}
|
||||||
|
{@const first = agentImages[0]}
|
||||||
|
{@const isCollapsed = collapsedImages[agentId] ?? true}
|
||||||
|
<section class="mb-8">
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
|
||||||
|
onclick={() => toggleImages(agentId)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
|
||||||
|
{isCollapsed ? '-rotate-90' : 'rotate-0'}"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
<span class="dot-running"></span>
|
||||||
|
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
|
||||||
|
{first.alias || first.hostname}
|
||||||
|
</h2>
|
||||||
|
{#if first.alias}
|
||||||
|
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-xs text-slate-600 ml-auto">
|
||||||
|
{agentImages.length} image{agentImages.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if !isCollapsed}
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<table class="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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each agentImages as img (img.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">
|
||||||
|
{#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}
|
||||||
|
</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>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════
|
||||||
|
VOLUMES TAB
|
||||||
|
════════════════════════════════════════════════════ -->
|
||||||
|
{:else if activeTab === "volumes"}
|
||||||
|
{#if volumes === null}
|
||||||
|
<div class="flex flex-col items-center justify-center h-64 gap-3 text-slate-600">
|
||||||
|
<div class="w-8 h-8 border-2 border-cyan-400/30 border-t-cyan-400 rounded-full animate-spin"></div>
|
||||||
|
<span class="text-sm">Chargement…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if loadError}
|
||||||
|
<div class="flex items-center gap-3 max-w-md mx-auto mt-16 p-4 card border-signal-red/20">
|
||||||
|
<span class="text-signal-red text-xl">⚠</span>
|
||||||
|
<p class="text-signal-red text-sm">{loadError}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if sortedAgentVolumes.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center h-64 gap-2 text-slate-600">
|
||||||
|
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm">Aucun volume disponible</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
{#each sortedAgentVolumes as [agentId, agentVolumes]}
|
||||||
|
{@const first = agentVolumes[0]}
|
||||||
|
{@const isCollapsed = collapsedVolumes[agentId] ?? true}
|
||||||
|
<section class="mb-8">
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
|
||||||
|
onclick={() => toggleVolumes(agentId)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
|
||||||
|
{isCollapsed ? '-rotate-90' : 'rotate-0'}"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
<span class="dot-running"></span>
|
||||||
|
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
|
||||||
|
{first.alias || first.hostname}
|
||||||
|
</h2>
|
||||||
|
{#if first.alias}
|
||||||
|
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-xs text-slate-600 ml-auto">
|
||||||
|
{agentVolumes.length} volume{agentVolumes.length !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if !isCollapsed}
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<table class="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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each agentVolumes 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>
|
||||||
|
<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>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- ═══════════════════════════════════════════════════
|
||||||
|
NETWORKS TAB
|
||||||
|
════════════════════════════════════════════════════ -->
|
||||||
|
{:else if activeTab === "networks"}
|
||||||
|
{#if networks === null}
|
||||||
|
<div class="flex flex-col items-center justify-center h-64 gap-3 text-slate-600">
|
||||||
|
<div class="w-8 h-8 border-2 border-cyan-400/30 border-t-cyan-400 rounded-full animate-spin"></div>
|
||||||
|
<span class="text-sm">Chargement…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if loadError}
|
||||||
|
<div class="flex items-center gap-3 max-w-md mx-auto mt-16 p-4 card border-signal-red/20">
|
||||||
|
<span class="text-signal-red text-xl">⚠</span>
|
||||||
|
<p class="text-signal-red text-sm">{loadError}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else if sortedAgentNetworks.length === 0}
|
||||||
|
<div class="flex flex-col items-center justify-center h-64 gap-2 text-slate-600">
|
||||||
|
<svg class="w-10 h-10 opacity-30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||||
|
d="M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm">Aucun réseau disponible</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
{#each sortedAgentNetworks as [agentId, agentNetworks]}
|
||||||
|
{@const first = agentNetworks[0]}
|
||||||
|
{@const isCollapsed = collapsedNetworks[agentId] ?? true}
|
||||||
|
<section class="mb-8">
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2.5 mb-3 px-1 w-full text-left cursor-pointer group"
|
||||||
|
onclick={() => toggleNetworks(agentId)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="w-3.5 h-3.5 text-slate-500 transition-transform duration-200 shrink-0
|
||||||
|
{isCollapsed ? '-rotate-90' : 'rotate-0'}"
|
||||||
|
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
<span class="dot-running"></span>
|
||||||
|
<h2 class="font-semibold text-slate-200 text-sm group-hover:text-slate-100 transition-colors">
|
||||||
|
{first.alias || first.hostname}
|
||||||
|
</h2>
|
||||||
|
{#if first.alias}
|
||||||
|
<span class="font-mono text-xs text-slate-600">{first.hostname}</span>
|
||||||
|
{/if}
|
||||||
|
<span class="text-xs text-slate-600 ml-auto">
|
||||||
|
{agentNetworks.length} réseau{agentNetworks.length !== 1 ? "x" : ""}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if !isCollapsed}
|
||||||
|
<div class="card overflow-hidden">
|
||||||
|
<table class="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>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each agentNetworks 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>
|
||||||
|
<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
|
||||||
|
{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>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 font-mono text-xs text-slate-600">{shortId(net.id)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{/key}
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user