fix: fix mobile view + add orphean deletions

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

View File

@ -1045,3 +1045,249 @@ func TestComposeAction_Timeout(t *testing.T) {
t.Errorf("expected 504 or 404, got %d", w.Code)
}
}
// ── ListImages is_orphan ──────────────────────────────────────────────────────
func TestListImages_IsOrphan(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
reg.UpdateResources("a1",
nil,
[]*agentv1.ImageInfo{
{Id: "sha256:orphan", Tags: []string{}, Size: 10000, CreatedAt: 1700000000, IsOrphan: true},
{Id: "sha256:used", Tags: []string{"app:latest"}, Size: 20000, CreatedAt: 1700000001, IsOrphan: false},
},
nil,
nil,
)
req := httptest.NewRequest(http.MethodGet, "/api/v1/images", nil)
w := httptest.NewRecorder()
h.ListImages(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var out []struct {
ID string `json:"id"`
IsOrphan bool `json:"is_orphan"`
}
json.NewDecoder(w.Body).Decode(&out)
if len(out) != 2 {
t.Fatalf("expected 2 images, got %d", len(out))
}
for _, item := range out {
if item.ID == "sha256:orphan" && !item.IsOrphan {
t.Error("expected sha256:orphan to have is_orphan=true")
}
if item.ID == "sha256:used" && item.IsOrphan {
t.Error("expected sha256:used to have is_orphan=false")
}
}
}
// ── ListVolumes is_orphan ─────────────────────────────────────────────────────
func TestListVolumes_IsOrphan(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
reg.UpdateResources("a1",
nil,
nil,
[]*agentv1.VolumeInfo{
{Name: "orphaned-vol", Driver: "local", Mountpoint: "/var/lib/docker/volumes/orphaned-vol/_data", IsOrphan: true},
{Name: "active-vol", Driver: "local", Mountpoint: "/var/lib/docker/volumes/active-vol/_data", IsOrphan: false},
},
nil,
)
req := httptest.NewRequest(http.MethodGet, "/api/v1/volumes", nil)
w := httptest.NewRecorder()
h.ListVolumes(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var out []struct {
Name string `json:"name"`
IsOrphan bool `json:"is_orphan"`
}
json.NewDecoder(w.Body).Decode(&out)
if len(out) != 2 {
t.Fatalf("expected 2 volumes, got %d", len(out))
}
for _, item := range out {
if item.Name == "orphaned-vol" && !item.IsOrphan {
t.Error("expected orphaned-vol to have is_orphan=true")
}
if item.Name == "active-vol" && item.IsOrphan {
t.Error("expected active-vol to have is_orphan=false")
}
}
}
// ── ListNetworks is_orphan ────────────────────────────────────────────────────
func TestListNetworks_IsOrphan(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "host1", "alias1", "10.0.0.1", "amd64", "linux")
reg.UpdateResources("a1",
nil,
nil,
nil,
[]*agentv1.NetworkInfo{
{Id: "net-orphan", Name: "stale-net", Driver: "bridge", Scope: "local", IsOrphan: true},
{Id: "net-used", Name: "active-net", Driver: "bridge", Scope: "local", IsOrphan: false},
},
)
req := httptest.NewRequest(http.MethodGet, "/api/v1/networks", nil)
w := httptest.NewRecorder()
h.ListNetworks(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var out []struct {
ID string `json:"id"`
IsOrphan bool `json:"is_orphan"`
}
json.NewDecoder(w.Body).Decode(&out)
if len(out) != 2 {
t.Fatalf("expected 2 networks, got %d", len(out))
}
for _, item := range out {
if item.ID == "net-orphan" && !item.IsOrphan {
t.Error("expected net-orphan to have is_orphan=true")
}
if item.ID == "net-used" && item.IsOrphan {
t.Error("expected net-used to have is_orphan=false")
}
}
}
// ── DeleteImage ───────────────────────────────────────────────────────────────
func TestDeleteImage_AgentNotConnected(t *testing.T) {
h, _, _, _ := newTestHandler(t)
router := chi.NewRouter()
router.Delete("/api/v1/agents/{agentID}/images/{imageID}", h.DeleteImage)
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/ghost/images/sha256:abc", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
}
func TestDeleteImage_Success(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "h", "a", "ip", "arch", "os")
router := chi.NewRouter()
router.Delete("/api/v1/agents/{agentID}/images/{imageID}", h.DeleteImage)
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/a1/images/sha256:abc?force=true", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String())
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["command_id"] == "" {
t.Error("expected command_id in response")
}
}
// ── DeleteVolume ──────────────────────────────────────────────────────────────
func TestDeleteVolume_AgentNotConnected(t *testing.T) {
h, _, _, _ := newTestHandler(t)
router := chi.NewRouter()
router.Delete("/api/v1/agents/{agentID}/volumes/{volumeName}", h.DeleteVolume)
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/ghost/volumes/my-vol", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
}
func TestDeleteVolume_Success(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "h", "a", "ip", "arch", "os")
router := chi.NewRouter()
router.Delete("/api/v1/agents/{agentID}/volumes/{volumeName}", h.DeleteVolume)
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/a1/volumes/my-vol", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String())
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["command_id"] == "" {
t.Error("expected command_id in response")
}
}
// ── DeleteNetwork ─────────────────────────────────────────────────────────────
func TestDeleteNetwork_AgentNotConnected(t *testing.T) {
h, _, _, _ := newTestHandler(t)
router := chi.NewRouter()
router.Delete("/api/v1/agents/{agentID}/networks/{networkID}", h.DeleteNetwork)
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/ghost/networks/net1", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
}
func TestDeleteNetwork_Success(t *testing.T) {
h, _, reg, _ := newTestHandler(t)
reg.Register("a1", "h", "a", "ip", "arch", "os")
router := chi.NewRouter()
router.Delete("/api/v1/agents/{agentID}/networks/{networkID}", h.DeleteNetwork)
req, _ := http.NewRequest(http.MethodDelete, "/api/v1/agents/a1/networks/net1", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d — body: %s", w.Code, w.Body.String())
}
var resp map[string]string
json.NewDecoder(w.Body).Decode(&resp)
if resp["command_id"] == "" {
t.Error("expected command_id in response")
}
}

View File

@ -191,6 +191,7 @@ func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) {
Tags []string `json:"tags"`
Size int64 `json:"size"`
CreatedAt int64 `json:"created_at"`
IsOrphan bool `json:"is_orphan"`
}
var out []imageDTO
for _, agent := range h.registry.List() {
@ -204,12 +205,35 @@ func (h *Handler) ListImages(w http.ResponseWriter, r *http.Request) {
Tags: func() []string { if t := img.GetTags(); t != nil { return t }; return []string{} }(),
Size: img.GetSize(),
CreatedAt: img.GetCreatedAt(),
IsOrphan: img.GetIsOrphan(),
})
}
}
jsonOK(w, out)
}
func (h *Handler) DeleteImage(w http.ResponseWriter, r *http.Request) {
agentID := chi.URLParam(r, "agentID")
imageID := chi.URLParam(r, "imageID")
force := r.URL.Query().Get("force") == "true"
cmdID := uuid.NewString()
sent := h.registry.Send(agentID, &agentv1.ServerMessage{
Payload: &agentv1.ServerMessage_DeleteImage{
DeleteImage: &agentv1.DeleteImageCommand{
CommandId: cmdID,
ImageId: imageID,
Force: force,
},
},
})
if !sent {
http.Error(w, "agent not connected", http.StatusServiceUnavailable)
return
}
jsonOK(w, map[string]string{"command_id": cmdID})
}
// ── Volumes ───────────────────────────────────────────────────────────────────
func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) {
@ -221,6 +245,7 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) {
Name string `json:"name"`
Driver string `json:"driver"`
Mountpoint string `json:"mountpoint"`
IsOrphan bool `json:"is_orphan"`
}
var out []volumeDTO
for _, agent := range h.registry.List() {
@ -233,12 +258,35 @@ func (h *Handler) ListVolumes(w http.ResponseWriter, r *http.Request) {
Name: vol.GetName(),
Driver: vol.GetDriver(),
Mountpoint: vol.GetMountpoint(),
IsOrphan: vol.GetIsOrphan(),
})
}
}
jsonOK(w, out)
}
func (h *Handler) DeleteVolume(w http.ResponseWriter, r *http.Request) {
agentID := chi.URLParam(r, "agentID")
volumeName := chi.URLParam(r, "volumeName")
force := r.URL.Query().Get("force") == "true"
cmdID := uuid.NewString()
sent := h.registry.Send(agentID, &agentv1.ServerMessage{
Payload: &agentv1.ServerMessage_DeleteVolume{
DeleteVolume: &agentv1.DeleteVolumeCommand{
CommandId: cmdID,
VolumeName: volumeName,
Force: force,
},
},
})
if !sent {
http.Error(w, "agent not connected", http.StatusServiceUnavailable)
return
}
jsonOK(w, map[string]string{"command_id": cmdID})
}
// ── Networks ──────────────────────────────────────────────────────────────────
func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
@ -251,6 +299,7 @@ func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
Name string `json:"name"`
Driver string `json:"driver"`
Scope string `json:"scope"`
IsOrphan bool `json:"is_orphan"`
}
var out []networkDTO
for _, agent := range h.registry.List() {
@ -264,12 +313,33 @@ func (h *Handler) ListNetworks(w http.ResponseWriter, r *http.Request) {
Name: net.GetName(),
Driver: net.GetDriver(),
Scope: net.GetScope(),
IsOrphan: net.GetIsOrphan(),
})
}
}
jsonOK(w, out)
}
func (h *Handler) DeleteNetwork(w http.ResponseWriter, r *http.Request) {
agentID := chi.URLParam(r, "agentID")
networkID := chi.URLParam(r, "networkID")
cmdID := uuid.NewString()
sent := h.registry.Send(agentID, &agentv1.ServerMessage{
Payload: &agentv1.ServerMessage_DeleteNetwork{
DeleteNetwork: &agentv1.DeleteNetworkCommand{
CommandId: cmdID,
NetworkId: networkID,
},
},
})
if !sent {
http.Error(w, "agent not connected", http.StatusServiceUnavailable)
return
}
jsonOK(w, map[string]string{"command_id": cmdID})
}
// ── Agent token provisioning ──────────────────────────────────────────────────
func (h *Handler) CreateAgentToken(w http.ResponseWriter, r *http.Request) {

View File

@ -50,6 +50,9 @@ func NewRouter(h *Handler) http.Handler {
r.Get("/volumes", h.ListVolumes)
r.Get("/networks", h.ListNetworks)
r.Post("/agents/{agentID}/containers/{containerID}/action", h.ContainerAction)
r.Delete("/agents/{agentID}/images/{imageID}", h.DeleteImage)
r.Delete("/agents/{agentID}/volumes/{volumeName}", h.DeleteVolume)
r.Delete("/agents/{agentID}/networks/{networkID}", h.DeleteNetwork)
r.Get("/agents/{agentID}/containers/{containerID}/logs", h.LogsWS)
r.Get("/events", h.EventsWS)
r.Get("/agents/{agentID}/fs/list", h.FsList)