From 64d7c9f569312de0884b7891dcd011f3647fd349 Mon Sep 17 00:00:00 2001 From: kuhree Date: Mon, 24 Mar 2025 19:51:43 -0400 Subject: [PATCH 01/10] feat: adds widget-swarm-servicesa --- internal/glance/templates/swarm-services.html | 82 ++++ internal/glance/widget-swarm-services.go | 369 ++++++++++++++++++ internal/glance/widget.go | 2 + 3 files changed, 453 insertions(+) create mode 100644 internal/glance/templates/swarm-services.html create mode 100644 internal/glance/widget-swarm-services.go diff --git a/internal/glance/templates/swarm-services.html b/internal/glance/templates/swarm-services.html new file mode 100644 index 00000000..f4b12f8c --- /dev/null +++ b/internal/glance/templates/swarm-services.html @@ -0,0 +1,82 @@ +{{ template "widget-base.html" . }} + +{{- define "widget-content" }} + +{{- end }} + +{{- define "state-icon" }} +{{- if eq . "ok" }} + +{{- else if eq . "warn" }} + +{{- else if eq . "paused" }} + +{{- else }} + +{{- end }} +{{- end }} diff --git a/internal/glance/widget-swarm-services.go b/internal/glance/widget-swarm-services.go new file mode 100644 index 00000000..d69a1c68 --- /dev/null +++ b/internal/glance/widget-swarm-services.go @@ -0,0 +1,369 @@ +package glance + +import ( + "context" + "encoding/json" + "fmt" + "html/template" + "net" + "net/http" + "sort" + "strings" + "time" +) + +var swarmServicesWidgetTemplate = mustParseTemplate("swarm-services.html", "widget-base.html") + +type swarmServicesWidget struct { + widgetBase `yaml:",inline"` + HideByDefault bool `yaml:"hide-by-default"` + SockPath string `yaml:"sock-path"` + Services swarmServiceList `yaml:"-"` +} + +func (widget *swarmServicesWidget) initialize() error { + widget.withTitle("Swarm Services").withCacheDuration(1 * time.Minute) + + if widget.SockPath == "" { + widget.SockPath = "/var/run/docker.sock" + } + + return nil +} + +func (widget *swarmServicesWidget) update(ctx context.Context) { + services, err := fetchSwarmServices(widget.SockPath, widget.HideByDefault) + if !widget.canContinueUpdateAfterHandlingErr(err) { + return + } + + services.sortByStateIconThenTitle() + widget.Services = services +} + +func (widget *swarmServicesWidget) Render() template.HTML { + return widget.renderTemplate(widget, swarmServicesWidgetTemplate) +} + +const ( + swarmServicesLabelHide = "glance.hide" + swarmServiceLabelName = "glance.name" + swarmServiceLabelURL = "glance.url" + swarmServiceLabelDescription = "glance.description" + swarmServiceLabelSameTab = "glance.same-tab" + swarmServiceLabelIcon = "glance.icon" + swarmServiceLabelID = "glance.id" + swarmServiceLabelParent = "glance.parent" +) + +const ( + swarmServiceStateIconOK = "ok" + swarmServiceStateIconPending = "pending" + swarmServiceStateIconWarn = "warn" + swarmServiceStateIconOther = "other" +) + +var swarmServiceStateIconPriorities = map[string]int{ + swarmServiceStateIconWarn: 0, + swarmServiceStateIconOther: 1, + swarmServiceStateIconPending: 2, + swarmServiceStateIconOK: 3, +} + +type swarmServiceJsonResponse struct { + ID string `json:"ID"` + Spec swarmServiceSpec `json:"Spec"` +} + +type swarmServiceSpec struct { + Name string `json:"Name"` + Labels swarmServiceLabels `json:"Labels"` + TaskTemplate swarmServiceTaskTemplate `json:"TaskTemplate"` +} + +type swarmServiceLabels map[string]string + +type swarmServiceTaskTemplate struct { + ContainerSpec swarmServiceContainerSpec `json:"ContainerSpec"` +} + +type swarmServiceContainerSpec struct { + Image string `json:"Image"` +} + +type swarmTaskJsonResponse struct { + ID string `json:"ID"` + ServiceID string `json:"ServiceID"` + Status struct { + State string `json:"State"` + Message string `json:"Message"` + } `json:"Status"` +} + +type swarmExtendedService struct { + *swarmServiceJsonResponse `json:"Service"` + State string `json:"State"` + Status string `json:"Status"` +} + +func (l *swarmServiceLabels) getOrDefault(label, def string) string { + if l == nil { + return def + } + + v, ok := (*l)[label] + if !ok { + return def + } + + if v == "" { + return def + } + + return v +} + +type swarmService struct { + Title string + URL string + SameTab bool + Image string + State string + StateText string + StateIcon string + Description string + Icon customIconField + Children swarmServiceList +} + +type swarmServiceList []swarmService + +func (services swarmServiceList) sortByStateIconThenTitle() { + p := &swarmServiceStateIconPriorities + + sort.SliceStable(services, func(a, b int) bool { + if services[a].StateIcon != services[b].StateIcon { + return (*p)[services[a].StateIcon] < (*p)[services[b].StateIcon] + } + + return strings.ToLower(services[a].Title) < strings.ToLower(services[b].Title) + }) +} + +func swarmServiceStateToStateIcon(state string) string { + switch state { + case "running", "complete": + return swarmServiceStateIconOK + case "new", "pending", "assigned", "accepted", "ready", "preparing", "starting": + return swarmServiceStateIconPending + case "failed", "shutdown", "rejected", "orphaned", "remove": + return swarmServiceStateIconWarn + default: + return swarmServiceStateIconOther + } +} + +func fetchSwarmServices(socketPath string, hideByDefault bool) (swarmServiceList, error) { + svcs, err := fetchAllSwarmServicesFromSock(socketPath) + if err != nil { + return nil, fmt.Errorf("fetching services: %w", err) + } + + tasks, err := fetchAllSwarmTasksFromSock(socketPath) + if err != nil { + return nil, fmt.Errorf("fetching tasks: %w", err) + } + + services := groupSwarmServiceTasks(svcs, tasks) + services, children := groupSwarmServiceChildren(services, hideByDefault) + + swarmServices := make(swarmServiceList, 0, len(services)) + + for i := range services { + service := &services[i] + + dc := swarmService{ + Title: deriveSwarmServiceTitle(service), + URL: service.Spec.Labels.getOrDefault(swarmServiceLabelURL, ""), + Description: service.Spec.Labels.getOrDefault(swarmServiceLabelDescription, ""), + SameTab: stringToBool(service.Spec.Labels.getOrDefault(swarmServiceLabelSameTab, "false")), + Image: service.Spec.TaskTemplate.ContainerSpec.Image, + State: strings.ToLower(service.State), + StateText: strings.ToLower(service.Status), + Icon: newCustomIconField(service.Spec.Labels.getOrDefault(swarmServiceLabelIcon, "si:docker")), + } + + if idValue := service.Spec.Labels.getOrDefault(swarmServiceLabelID, ""); idValue != "" { + if children, ok := children[idValue]; ok { + for i := range children { + child := &children[i] + dc.Children = append(dc.Children, swarmService{ + Title: deriveSwarmServiceTitle(child), + StateText: child.Status, + StateIcon: swarmServiceStateToStateIcon(strings.ToLower(child.State)), + }) + } + } + } + + dc.Children.sortByStateIconThenTitle() + + stateIconSupersededByChild := false + for i := range dc.Children { + if dc.Children[i].StateIcon == swarmServiceStateIconWarn { + dc.StateIcon = swarmServiceStateIconWarn + stateIconSupersededByChild = true + break + } + } + if !stateIconSupersededByChild { + dc.StateIcon = swarmServiceStateToStateIcon(dc.State) + } + + swarmServices = append(swarmServices, dc) + } + + return swarmServices, nil +} + +func deriveSwarmServiceTitle(service *swarmExtendedService) string { + if v := service.Spec.Labels.getOrDefault(swarmServiceLabelName, ""); v != "" { + return v + } + + return strings.TrimLeft(service.Spec.Name, "/") +} + +func groupSwarmServiceChildren( + services []swarmExtendedService, + hideByDefault bool, +) ( + []swarmExtendedService, + map[string][]swarmExtendedService, +) { + parents := make([]swarmExtendedService, 0, len(services)) + children := make(map[string][]swarmExtendedService) + + for i := range services { + service := &services[i] + + if isSwarmServiceHidden(service, hideByDefault) { + continue + } + + isParent := service.Spec.Labels.getOrDefault(swarmServiceLabelID, "") != "" + parent := service.Spec.Labels.getOrDefault(swarmServiceLabelParent, "") + + if !isParent && parent != "" { + children[parent] = append(children[parent], *service) + } else { + parents = append(parents, *service) + } + } + + return parents, children +} + +func groupSwarmServiceTasks( + services []swarmServiceJsonResponse, + tasks []swarmTaskJsonResponse, +) []swarmExtendedService { + servicesWithTasks := make([]swarmExtendedService, 0, len(services)) + + for i := range services { + service := &services[i] + extended := swarmExtendedService{service, "unknown", "no tasks found"} + + for _, task := range tasks { + // TODO: Is there a better way to "prioritize" running tasks over shutdown? + if task.Status.State == "running" { + extended.State = task.Status.State + extended.Status = task.Status.Message + break + } + + if task.ServiceID == service.ID { + extended.State = task.Status.State + extended.Status = task.Status.Message + } + } + + servicesWithTasks = append(servicesWithTasks, extended) + } + + return servicesWithTasks +} + +func isSwarmServiceHidden(service *swarmExtendedService, hideByDefault bool) bool { + if v := service.Spec.Labels.getOrDefault(swarmServicesLabelHide, ""); v != "" { + return stringToBool(v) + } + + return hideByDefault +} + +func fetchAllSwarmServicesFromSock(socketPath string) ([]swarmServiceJsonResponse, error) { + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + }, + } + + request, err := http.NewRequest("GET", "http://docker/services", nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("sending request to socket: %w", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("non-200 response status: %s", response.Status) + } + + var services []swarmServiceJsonResponse + if err := json.NewDecoder(response.Body).Decode(&services); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + return services, nil +} + +func fetchAllSwarmTasksFromSock(socketPath string) ([]swarmTaskJsonResponse, error) { + client := &http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", socketPath) + }, + }, + } + + request, err := http.NewRequest("GET", "http://docker/tasks", nil) + if err != nil { + return nil, fmt.Errorf("creating request: %w", err) + } + + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("sending request to socket: %w", err) + } + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("non-200 response status: %s", response.Status) + } + + var tasks []swarmTaskJsonResponse + if err := json.NewDecoder(response.Body).Decode(&tasks); err != nil { + return nil, fmt.Errorf("decoding response: %w", err) + } + + return tasks, nil +} diff --git a/internal/glance/widget.go b/internal/glance/widget.go index 7c301832..da697417 100644 --- a/internal/glance/widget.go +++ b/internal/glance/widget.go @@ -77,6 +77,8 @@ func newWidget(widgetType string) (widget, error) { w = &customAPIWidget{} case "docker-containers": w = &dockerContainersWidget{} + case "swarm-services": + w = &swarmServicesWidget{} case "server-stats": w = &serverStatsWidget{} default: From d010b7a7d222b5fa61838264d01d4b5438ec2018 Mon Sep 17 00:00:00 2001 From: kuhree Date: Mon, 24 Mar 2025 22:34:53 -0400 Subject: [PATCH 02/10] refactor(docker): toggle swarm-mode on docker-containers --- internal/glance/templates/swarm-services.html | 82 ------ internal/glance/widget-docker-containers.go | 72 +++-- internal/glance/widget-swarm-services.go | 260 +++--------------- internal/glance/widget.go | 2 - 4 files changed, 83 insertions(+), 333 deletions(-) delete mode 100644 internal/glance/templates/swarm-services.html diff --git a/internal/glance/templates/swarm-services.html b/internal/glance/templates/swarm-services.html deleted file mode 100644 index f4b12f8c..00000000 --- a/internal/glance/templates/swarm-services.html +++ /dev/null @@ -1,82 +0,0 @@ -{{ template "widget-base.html" . }} - -{{- define "widget-content" }} - -{{- end }} - -{{- define "state-icon" }} -{{- if eq . "ok" }} - -{{- else if eq . "warn" }} - -{{- else if eq . "paused" }} - -{{- else }} - -{{- end }} -{{- end }} diff --git a/internal/glance/widget-docker-containers.go b/internal/glance/widget-docker-containers.go index f38cdeb8..8a64d0ba 100644 --- a/internal/glance/widget-docker-containers.go +++ b/internal/glance/widget-docker-containers.go @@ -18,6 +18,7 @@ type dockerContainersWidget struct { widgetBase `yaml:",inline"` HideByDefault bool `yaml:"hide-by-default"` SockPath string `yaml:"sock-path"` + SwarmMode bool `yaml:"swarm-mode"` Containers dockerContainerList `yaml:"-"` } @@ -32,7 +33,14 @@ func (widget *dockerContainersWidget) initialize() error { } func (widget *dockerContainersWidget) update(ctx context.Context) { - containers, err := fetchDockerContainers(widget.SockPath, widget.HideByDefault) + var containers dockerContainerList + var err error + if widget.SwarmMode { + containers, err = fetchSwarmServices(widget.SockPath, widget.HideByDefault) + } else { + containers, err = fetchDockerContainers(widget.SockPath, widget.HideByDefault) + } + if !widget.canContinueUpdateAfterHandlingErr(err) { return } @@ -57,17 +65,19 @@ const ( ) const ( - dockerContainerStateIconOK = "ok" - dockerContainerStateIconPaused = "paused" - dockerContainerStateIconWarn = "warn" - dockerContainerStateIconOther = "other" + dockerContainerStateIconOK = "ok" + dockerContainerStateIconPaused = "paused" + dockerContainerStateIconPending = "pending" + dockerContainerStateIconWarn = "warn" + dockerContainerStateIconOther = "other" ) var dockerContainerStateIconPriorities = map[string]int{ - dockerContainerStateIconWarn: 0, - dockerContainerStateIconOther: 1, - dockerContainerStateIconPaused: 2, - dockerContainerStateIconOK: 3, + dockerContainerStateIconWarn: 0, + dockerContainerStateIconOther: 1, + dockerContainerStateIconPaused: 2, + dockerContainerStateIconPending: 3, + dockerContainerStateIconOK: 4, } type dockerContainerJsonResponse struct { @@ -126,11 +136,13 @@ func (containers dockerContainerList) sortByStateIconThenTitle() { func dockerContainerStateToStateIcon(state string) string { switch state { - case "running": + case "running", "complete": return dockerContainerStateIconOK case "paused": return dockerContainerStateIconPaused - case "exited", "unhealthy", "dead": + case "new", "pending", "assigned", "accepted", "ready", "preparing", "starting": + return dockerContainerStateIconPending + case "exited", "unhealthy", "dead", "failed", "shutdown", "rejected", "orphaned", "remove": return dockerContainerStateIconWarn default: return dockerContainerStateIconOther @@ -150,7 +162,7 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain container := &containers[i] dc := dockerContainer{ - Title: deriveDockerContainerTitle(container), + Title: deriveDockerContainerTitle(&container.Labels, itemAtIndexOrDefault(container.Names, 0, "n/a")), URL: container.Labels.getOrDefault(dockerContainerLabelURL, ""), Description: container.Labels.getOrDefault(dockerContainerLabelDescription, ""), SameTab: stringToBool(container.Labels.getOrDefault(dockerContainerLabelSameTab, "false")), @@ -165,7 +177,7 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain for i := range children { child := &children[i] dc.Children = append(dc.Children, dockerContainer{ - Title: deriveDockerContainerTitle(child), + Title: deriveDockerContainerTitle(&container.Labels, itemAtIndexOrDefault(container.Names, 0, "n/a")), StateText: child.Status, StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)), }) @@ -193,12 +205,12 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain return dockerContainers, nil } -func deriveDockerContainerTitle(container *dockerContainerJsonResponse) string { - if v := container.Labels.getOrDefault(dockerContainerLabelName, ""); v != "" { +func deriveDockerContainerTitle(labels *dockerContainerLabels, name string) string { + if v := labels.getOrDefault(dockerContainerLabelName, ""); v != "" { return v } - return strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, "n/a"), "/") + return strings.TrimLeft(name, "/") } func groupDockerContainerChildren( @@ -214,7 +226,7 @@ func groupDockerContainerChildren( for i := range containers { container := &containers[i] - if isDockerContainerHidden(container, hideByDefault) { + if isDockerContainerHidden(&container.Labels, hideByDefault) { continue } @@ -231,15 +243,15 @@ func groupDockerContainerChildren( return parents, children } -func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefault bool) bool { - if v := container.Labels.getOrDefault(dockerContainerLabelHide, ""); v != "" { +func isDockerContainerHidden(labels *dockerContainerLabels, hideByDefault bool) bool { + if v := labels.getOrDefault(dockerContainerLabelHide, ""); v != "" { return stringToBool(v) } return hideByDefault } -func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) { +func fetchFromSock(socketPath string, path string, target any) error { client := &http.Client{ Timeout: 5 * time.Second, Transport: &http.Transport{ @@ -249,24 +261,32 @@ func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonR }, } - request, err := http.NewRequest("GET", "http://docker/containers/json?all=true", nil) + request, err := http.NewRequest("GET", path, nil) if err != nil { - return nil, fmt.Errorf("creating request: %w", err) + return fmt.Errorf("creating request: %w", err) } response, err := client.Do(request) if err != nil { - return nil, fmt.Errorf("sending request to socket: %w", err) + return fmt.Errorf("sending request to socket: %w", err) } defer response.Body.Close() if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("non-200 response status: %s", response.Status) + return fmt.Errorf("non-200 response status: %s", response.Status) + } + + if err := json.NewDecoder(response.Body).Decode(&target); err != nil { + return fmt.Errorf("decoding response: %w", err) } + return nil +} + +func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) { var containers []dockerContainerJsonResponse - if err := json.NewDecoder(response.Body).Decode(&containers); err != nil { - return nil, fmt.Errorf("decoding response: %w", err) + if err := fetchFromSock(socketPath, "http://docker/containers/json?all=true", &containers); err != nil { + return nil, err } return containers, nil diff --git a/internal/glance/widget-swarm-services.go b/internal/glance/widget-swarm-services.go index d69a1c68..0b9b35d1 100644 --- a/internal/glance/widget-swarm-services.go +++ b/internal/glance/widget-swarm-services.go @@ -1,103 +1,38 @@ package glance import ( - "context" - "encoding/json" "fmt" - "html/template" - "net" - "net/http" - "sort" "strings" - "time" ) -var swarmServicesWidgetTemplate = mustParseTemplate("swarm-services.html", "widget-base.html") - -type swarmServicesWidget struct { - widgetBase `yaml:",inline"` - HideByDefault bool `yaml:"hide-by-default"` - SockPath string `yaml:"sock-path"` - Services swarmServiceList `yaml:"-"` -} - -func (widget *swarmServicesWidget) initialize() error { - widget.withTitle("Swarm Services").withCacheDuration(1 * time.Minute) - - if widget.SockPath == "" { - widget.SockPath = "/var/run/docker.sock" - } - - return nil -} - -func (widget *swarmServicesWidget) update(ctx context.Context) { - services, err := fetchSwarmServices(widget.SockPath, widget.HideByDefault) - if !widget.canContinueUpdateAfterHandlingErr(err) { - return - } - - services.sortByStateIconThenTitle() - widget.Services = services -} - -func (widget *swarmServicesWidget) Render() template.HTML { - return widget.renderTemplate(widget, swarmServicesWidgetTemplate) -} - -const ( - swarmServicesLabelHide = "glance.hide" - swarmServiceLabelName = "glance.name" - swarmServiceLabelURL = "glance.url" - swarmServiceLabelDescription = "glance.description" - swarmServiceLabelSameTab = "glance.same-tab" - swarmServiceLabelIcon = "glance.icon" - swarmServiceLabelID = "glance.id" - swarmServiceLabelParent = "glance.parent" -) - -const ( - swarmServiceStateIconOK = "ok" - swarmServiceStateIconPending = "pending" - swarmServiceStateIconWarn = "warn" - swarmServiceStateIconOther = "other" -) - -var swarmServiceStateIconPriorities = map[string]int{ - swarmServiceStateIconWarn: 0, - swarmServiceStateIconOther: 1, - swarmServiceStateIconPending: 2, - swarmServiceStateIconOK: 3, -} - type swarmServiceJsonResponse struct { ID string `json:"ID"` Spec swarmServiceSpec `json:"Spec"` } -type swarmServiceSpec struct { - Name string `json:"Name"` - Labels swarmServiceLabels `json:"Labels"` - TaskTemplate swarmServiceTaskTemplate `json:"TaskTemplate"` +type swarmTaskJsonResponse struct { + ID string `json:"ID"` + ServiceID string `json:"ServiceID"` + Status swarmTaskStatus `json:"Status"` } -type swarmServiceLabels map[string]string +type swarmServiceSpec struct { + Name string `json:"Name"` + Labels dockerContainerLabels `json:"Labels"` + TaskTemplate swarmTaskTemplate `json:"TaskTemplate"` +} -type swarmServiceTaskTemplate struct { - ContainerSpec swarmServiceContainerSpec `json:"ContainerSpec"` +type swarmTaskTemplate struct { + ContainerSpec swarmContainerSpec `json:"ContainerSpec"` } -type swarmServiceContainerSpec struct { +type swarmContainerSpec struct { Image string `json:"Image"` } -type swarmTaskJsonResponse struct { - ID string `json:"ID"` - ServiceID string `json:"ServiceID"` - Status struct { - State string `json:"State"` - Message string `json:"Message"` - } `json:"Status"` +type swarmTaskStatus struct { + State string `json:"State"` + Message string `json:"Message"` } type swarmExtendedService struct { @@ -106,64 +41,7 @@ type swarmExtendedService struct { Status string `json:"Status"` } -func (l *swarmServiceLabels) getOrDefault(label, def string) string { - if l == nil { - return def - } - - v, ok := (*l)[label] - if !ok { - return def - } - - if v == "" { - return def - } - - return v -} - -type swarmService struct { - Title string - URL string - SameTab bool - Image string - State string - StateText string - StateIcon string - Description string - Icon customIconField - Children swarmServiceList -} - -type swarmServiceList []swarmService - -func (services swarmServiceList) sortByStateIconThenTitle() { - p := &swarmServiceStateIconPriorities - - sort.SliceStable(services, func(a, b int) bool { - if services[a].StateIcon != services[b].StateIcon { - return (*p)[services[a].StateIcon] < (*p)[services[b].StateIcon] - } - - return strings.ToLower(services[a].Title) < strings.ToLower(services[b].Title) - }) -} - -func swarmServiceStateToStateIcon(state string) string { - switch state { - case "running", "complete": - return swarmServiceStateIconOK - case "new", "pending", "assigned", "accepted", "ready", "preparing", "starting": - return swarmServiceStateIconPending - case "failed", "shutdown", "rejected", "orphaned", "remove": - return swarmServiceStateIconWarn - default: - return swarmServiceStateIconOther - } -} - -func fetchSwarmServices(socketPath string, hideByDefault bool) (swarmServiceList, error) { +func fetchSwarmServices(socketPath string, hideByDefault bool) (dockerContainerList, error) { svcs, err := fetchAllSwarmServicesFromSock(socketPath) if err != nil { return nil, fmt.Errorf("fetching services: %w", err) @@ -177,30 +55,30 @@ func fetchSwarmServices(socketPath string, hideByDefault bool) (swarmServiceList services := groupSwarmServiceTasks(svcs, tasks) services, children := groupSwarmServiceChildren(services, hideByDefault) - swarmServices := make(swarmServiceList, 0, len(services)) + swarmServices := make(dockerContainerList, 0, len(services)) for i := range services { service := &services[i] - dc := swarmService{ - Title: deriveSwarmServiceTitle(service), - URL: service.Spec.Labels.getOrDefault(swarmServiceLabelURL, ""), - Description: service.Spec.Labels.getOrDefault(swarmServiceLabelDescription, ""), - SameTab: stringToBool(service.Spec.Labels.getOrDefault(swarmServiceLabelSameTab, "false")), + dc := dockerContainer{ + Title: deriveDockerContainerTitle(&service.Spec.Labels, service.Spec.Name), + URL: service.Spec.Labels.getOrDefault(dockerContainerLabelURL, ""), + Description: service.Spec.Labels.getOrDefault(dockerContainerLabelDescription, ""), + SameTab: stringToBool(service.Spec.Labels.getOrDefault(dockerContainerLabelSameTab, "false")), Image: service.Spec.TaskTemplate.ContainerSpec.Image, State: strings.ToLower(service.State), StateText: strings.ToLower(service.Status), - Icon: newCustomIconField(service.Spec.Labels.getOrDefault(swarmServiceLabelIcon, "si:docker")), + Icon: newCustomIconField(service.Spec.Labels.getOrDefault(dockerContainerLabelIcon, "si:docker")), } - if idValue := service.Spec.Labels.getOrDefault(swarmServiceLabelID, ""); idValue != "" { + if idValue := service.Spec.Labels.getOrDefault(dockerContainerLabelID, ""); idValue != "" { if children, ok := children[idValue]; ok { for i := range children { child := &children[i] - dc.Children = append(dc.Children, swarmService{ - Title: deriveSwarmServiceTitle(child), + dc.Children = append(dc.Children, dockerContainer{ + Title: deriveDockerContainerTitle(&child.Spec.Labels, child.Spec.Name), StateText: child.Status, - StateIcon: swarmServiceStateToStateIcon(strings.ToLower(child.State)), + StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)), }) } } @@ -210,14 +88,14 @@ func fetchSwarmServices(socketPath string, hideByDefault bool) (swarmServiceList stateIconSupersededByChild := false for i := range dc.Children { - if dc.Children[i].StateIcon == swarmServiceStateIconWarn { - dc.StateIcon = swarmServiceStateIconWarn + if dc.Children[i].StateIcon == dockerContainerStateIconWarn { + dc.StateIcon = dockerContainerStateIconWarn stateIconSupersededByChild = true break } } if !stateIconSupersededByChild { - dc.StateIcon = swarmServiceStateToStateIcon(dc.State) + dc.StateIcon = dockerContainerStateToStateIcon(dc.State) } swarmServices = append(swarmServices, dc) @@ -226,14 +104,6 @@ func fetchSwarmServices(socketPath string, hideByDefault bool) (swarmServiceList return swarmServices, nil } -func deriveSwarmServiceTitle(service *swarmExtendedService) string { - if v := service.Spec.Labels.getOrDefault(swarmServiceLabelName, ""); v != "" { - return v - } - - return strings.TrimLeft(service.Spec.Name, "/") -} - func groupSwarmServiceChildren( services []swarmExtendedService, hideByDefault bool, @@ -247,12 +117,12 @@ func groupSwarmServiceChildren( for i := range services { service := &services[i] - if isSwarmServiceHidden(service, hideByDefault) { + if isDockerContainerHidden(&service.Spec.Labels, hideByDefault) { continue } - isParent := service.Spec.Labels.getOrDefault(swarmServiceLabelID, "") != "" - parent := service.Spec.Labels.getOrDefault(swarmServiceLabelParent, "") + isParent := service.Spec.Labels.getOrDefault(dockerContainerLabelID, "") != "" + parent := service.Spec.Labels.getOrDefault(dockerContainerLabelParent, "") if !isParent && parent != "" { children[parent] = append(children[parent], *service) @@ -294,75 +164,19 @@ func groupSwarmServiceTasks( return servicesWithTasks } -func isSwarmServiceHidden(service *swarmExtendedService, hideByDefault bool) bool { - if v := service.Spec.Labels.getOrDefault(swarmServicesLabelHide, ""); v != "" { - return stringToBool(v) - } - - return hideByDefault -} - func fetchAllSwarmServicesFromSock(socketPath string) ([]swarmServiceJsonResponse, error) { - client := &http.Client{ - Timeout: 5 * time.Second, - Transport: &http.Transport{ - DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { - return net.Dial("unix", socketPath) - }, - }, - } - - request, err := http.NewRequest("GET", "http://docker/services", nil) - if err != nil { - return nil, fmt.Errorf("creating request: %w", err) - } - - response, err := client.Do(request) - if err != nil { - return nil, fmt.Errorf("sending request to socket: %w", err) - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("non-200 response status: %s", response.Status) - } - var services []swarmServiceJsonResponse - if err := json.NewDecoder(response.Body).Decode(&services); err != nil { - return nil, fmt.Errorf("decoding response: %w", err) + if err := fetchFromSock(socketPath, "http://docker/services", &services); err != nil { + return nil, err } return services, nil } func fetchAllSwarmTasksFromSock(socketPath string) ([]swarmTaskJsonResponse, error) { - client := &http.Client{ - Timeout: 5 * time.Second, - Transport: &http.Transport{ - DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { - return net.Dial("unix", socketPath) - }, - }, - } - - request, err := http.NewRequest("GET", "http://docker/tasks", nil) - if err != nil { - return nil, fmt.Errorf("creating request: %w", err) - } - - response, err := client.Do(request) - if err != nil { - return nil, fmt.Errorf("sending request to socket: %w", err) - } - defer response.Body.Close() - - if response.StatusCode != http.StatusOK { - return nil, fmt.Errorf("non-200 response status: %s", response.Status) - } - var tasks []swarmTaskJsonResponse - if err := json.NewDecoder(response.Body).Decode(&tasks); err != nil { - return nil, fmt.Errorf("decoding response: %w", err) + if err := fetchFromSock(socketPath, "http://docker/tasks", &tasks); err != nil { + return nil, err } return tasks, nil diff --git a/internal/glance/widget.go b/internal/glance/widget.go index da697417..7c301832 100644 --- a/internal/glance/widget.go +++ b/internal/glance/widget.go @@ -77,8 +77,6 @@ func newWidget(widgetType string) (widget, error) { w = &customAPIWidget{} case "docker-containers": w = &dockerContainersWidget{} - case "swarm-services": - w = &swarmServicesWidget{} case "server-stats": w = &serverStatsWidget{} default: From cf5e6846a768ebb9b83f349005a150550da2df0b Mon Sep 17 00:00:00 2001 From: kuhree Date: Tue, 25 Mar 2025 13:55:24 -0400 Subject: [PATCH 03/10] chore: updates docs for docker-container's swarm-mode --- docs/configuration.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index 518252cb..3e7cea94 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1826,6 +1826,7 @@ If any of the child containers are down, their status will propagate up to the p | ---- | ---- | -------- | ------- | | hide-by-default | boolean | no | false | | sock-path | string | no | /var/run/docker.sock | +| swarm-mode | boolean | no | false | ##### `hide-by-default` Whether to hide the containers by default. If set to `true` you'll have to manually add a `glance.hide: false` label to each container you want to display. By default all containers will be shown and if you want to hide a specific container you can add a `glance.hide: true` label. @@ -1833,6 +1834,9 @@ Whether to hide the containers by default. If set to `true` you'll have to manua ##### `sock-path` The path to the Docker socket. +##### `swarm-mode` +Whether to enable Docker's swarm mode. If set to `true` swarm services are fetched instead of docker containers. + #### Labels | Name | Description | | ---- | ----------- | From 50c4719446b7834fca2166b7e420590941019315 Mon Sep 17 00:00:00 2001 From: kuhree Date: Tue, 25 Mar 2025 14:03:42 -0400 Subject: [PATCH 04/10] fix: swaps pending/paused priorities --- internal/glance/widget-docker-containers.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/glance/widget-docker-containers.go b/internal/glance/widget-docker-containers.go index 8a64d0ba..e055fb0f 100644 --- a/internal/glance/widget-docker-containers.go +++ b/internal/glance/widget-docker-containers.go @@ -66,8 +66,8 @@ const ( const ( dockerContainerStateIconOK = "ok" - dockerContainerStateIconPaused = "paused" dockerContainerStateIconPending = "pending" + dockerContainerStateIconPaused = "paused" dockerContainerStateIconWarn = "warn" dockerContainerStateIconOther = "other" ) @@ -75,8 +75,8 @@ const ( var dockerContainerStateIconPriorities = map[string]int{ dockerContainerStateIconWarn: 0, dockerContainerStateIconOther: 1, - dockerContainerStateIconPaused: 2, - dockerContainerStateIconPending: 3, + dockerContainerStateIconPending: 2, + dockerContainerStateIconPaused: 3, dockerContainerStateIconOK: 4, } From de52ffa02b36c6fd8d5a16833d495f0eee892c51 Mon Sep 17 00:00:00 2001 From: kuhree Date: Tue, 25 Mar 2025 15:35:35 -0400 Subject: [PATCH 05/10] refactor: merges swarm/docker, uses mode to toggle --- internal/glance/widget-docker-containers.go | 200 +++++++++++++++++--- internal/glance/widget-swarm-services.go | 183 ------------------ 2 files changed, 172 insertions(+), 211 deletions(-) delete mode 100644 internal/glance/widget-swarm-services.go diff --git a/internal/glance/widget-docker-containers.go b/internal/glance/widget-docker-containers.go index e055fb0f..256e4c0b 100644 --- a/internal/glance/widget-docker-containers.go +++ b/internal/glance/widget-docker-containers.go @@ -18,7 +18,7 @@ type dockerContainersWidget struct { widgetBase `yaml:",inline"` HideByDefault bool `yaml:"hide-by-default"` SockPath string `yaml:"sock-path"` - SwarmMode bool `yaml:"swarm-mode"` + Mode string `yaml:"mode"` Containers dockerContainerList `yaml:"-"` } @@ -33,14 +33,7 @@ func (widget *dockerContainersWidget) initialize() error { } func (widget *dockerContainersWidget) update(ctx context.Context) { - var containers dockerContainerList - var err error - if widget.SwarmMode { - containers, err = fetchSwarmServices(widget.SockPath, widget.HideByDefault) - } else { - containers, err = fetchDockerContainers(widget.SockPath, widget.HideByDefault) - } - + containers, err := fetchDockerContainers(widget.SockPath, widget.HideByDefault, widget.Mode) if !widget.canContinueUpdateAfterHandlingErr(err) { return } @@ -107,6 +100,79 @@ func (l *dockerContainerLabels) getOrDefault(label, def string) string { return v } +type swarmServiceJsonResponse struct { + ID string `json:"ID"` + Spec struct { + Name string `json:"Name"` + Labels dockerContainerLabels `json:"Labels"` + TaskTemplate struct { + ContainerSpec struct { + Image string `json:"Image"` + } `json:"ContainerSpec"` + } `json:"TaskTemplate"` + } `json:"Spec"` +} + +type swarmTaskJsonResponse struct { + ID string `json:"ID"` + CreatedAt string `json:"CreatedAt"` + UpdatedAt string `json:"UpdatedAt"` + ServiceID string `json:"ServiceID"` + Status struct { + State string `json:"State"` + Message string `json:"Message"` + } `json:"Status"` +} + +type swarmTaskJsonList []swarmTaskJsonResponse + +func (tasks swarmTaskJsonList) sortByUpdatedThenCreatedAt() { + sort.SliceStable(tasks, func(a, b int) bool { + updatedA, errA := time.Parse(time.RFC3339, tasks[a].UpdatedAt) + updatedB, errB := time.Parse(time.RFC3339, tasks[b].UpdatedAt) + + if errA == nil && errB == nil { + return updatedA.After(updatedB) + } + + createdA, errA := time.Parse(time.RFC3339, tasks[a].CreatedAt) + createdB, errB := time.Parse(time.RFC3339, tasks[b].CreatedAt) + + if errA == nil && errB == nil { + return createdA.After(createdB) + } + + return false + }) +} + +type swarmExtendedService struct { + *swarmServiceJsonResponse `json:"Service"` + State string `json:"State"` + Status string `json:"Status"` +} + +type swarmExtendedServiceList []swarmExtendedService + +func (services swarmExtendedServiceList) toContainerJsonList() []dockerContainerJsonResponse { + containers := make([]dockerContainerJsonResponse, 0, len(services)) + + for i := range services { + service := services[i] + container := dockerContainerJsonResponse{ + Names: []string{service.Spec.Name}, + Image: service.Spec.TaskTemplate.ContainerSpec.Image, + State: service.State, + Status: service.Status, + Labels: service.Spec.Labels, + } + + containers = append(containers, container) + } + + return containers +} + type dockerContainer struct { Title string URL string @@ -149,8 +215,8 @@ func dockerContainerStateToStateIcon(state string) string { } } -func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContainerList, error) { - containers, err := fetchAllDockerContainersFromSock(socketPath) +func fetchDockerContainers(socketPath string, hideByDefault bool, mode string) (dockerContainerList, error) { + containers, err := fetchContainersByMode(socketPath, mode) if err != nil { return nil, fmt.Errorf("fetching containers: %w", err) } @@ -162,7 +228,7 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain container := &containers[i] dc := dockerContainer{ - Title: deriveDockerContainerTitle(&container.Labels, itemAtIndexOrDefault(container.Names, 0, "n/a")), + Title: deriveDockerContainerTitle(container), URL: container.Labels.getOrDefault(dockerContainerLabelURL, ""), Description: container.Labels.getOrDefault(dockerContainerLabelDescription, ""), SameTab: stringToBool(container.Labels.getOrDefault(dockerContainerLabelSameTab, "false")), @@ -177,7 +243,7 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain for i := range children { child := &children[i] dc.Children = append(dc.Children, dockerContainer{ - Title: deriveDockerContainerTitle(&container.Labels, itemAtIndexOrDefault(container.Names, 0, "n/a")), + Title: deriveDockerContainerTitle(container), StateText: child.Status, StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)), }) @@ -205,12 +271,12 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain return dockerContainers, nil } -func deriveDockerContainerTitle(labels *dockerContainerLabels, name string) string { - if v := labels.getOrDefault(dockerContainerLabelName, ""); v != "" { +func deriveDockerContainerTitle(container *dockerContainerJsonResponse) string { + if v := container.Labels.getOrDefault(dockerContainerLabelName, ""); v != "" { return v } - return strings.TrimLeft(name, "/") + return strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, "n/a"), "/") } func groupDockerContainerChildren( @@ -226,7 +292,7 @@ func groupDockerContainerChildren( for i := range containers { container := &containers[i] - if isDockerContainerHidden(&container.Labels, hideByDefault) { + if isDockerContainerHidden(container, hideByDefault) { continue } @@ -243,14 +309,101 @@ func groupDockerContainerChildren( return parents, children } -func isDockerContainerHidden(labels *dockerContainerLabels, hideByDefault bool) bool { - if v := labels.getOrDefault(dockerContainerLabelHide, ""); v != "" { +func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefault bool) bool { + if v := container.Labels.getOrDefault(dockerContainerLabelHide, ""); v != "" { return stringToBool(v) } return hideByDefault } +func fetchContainersByMode(socketPath string, mode string) ([]dockerContainerJsonResponse, error) { + switch mode { + case "swarm": + return getSwarmContainers(socketPath) + default: + return getDockerContainers(socketPath) + } +} + +func getSwarmContainers(socketPath string) ([]dockerContainerJsonResponse, error) { + svcs, err := fetchAllSwarmServicesFromSock(socketPath) + if err != nil { + return nil, fmt.Errorf("fetching services: %w", err) + } + + tasks, err := fetchAllSwarmTasksFromSock(socketPath) + if err != nil { + return nil, fmt.Errorf("fetching tasks: %w", err) + } + + tasks.sortByUpdatedThenCreatedAt() + + services := extendSwarmServices(svcs, tasks) + containers := services.toContainerJsonList() + return containers, nil +} + +func getDockerContainers(socketPath string) ([]dockerContainerJsonResponse, error) { + containers, err := fetchAllDockerContainersFromSock(socketPath) + if err != nil { + return nil, fmt.Errorf("fetching swarm containers: %w", err) + } + + return containers, nil +} + +func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) { + var containers []dockerContainerJsonResponse + if err := fetchFromSock(socketPath, "http://docker/containers/json?all=true", &containers); err != nil { + return nil, err + } + + return containers, nil +} + +func fetchAllSwarmServicesFromSock(socketPath string) ([]swarmServiceJsonResponse, error) { + var services []swarmServiceJsonResponse + if err := fetchFromSock(socketPath, "http://docker/services", &services); err != nil { + return nil, err + } + + return services, nil +} + +func fetchAllSwarmTasksFromSock(socketPath string) (swarmTaskJsonList, error) { + var tasks []swarmTaskJsonResponse + if err := fetchFromSock(socketPath, "http://docker/tasks", &tasks); err != nil { + return nil, err + } + + return tasks, nil +} + +func extendSwarmServices( + services []swarmServiceJsonResponse, + tasks swarmTaskJsonList, +) swarmExtendedServiceList { + servicesWithTasks := make([]swarmExtendedService, 0, len(services)) + + for i := range services { + service := &services[i] + extended := swarmExtendedService{service, "unknown", "no tasks found"} + + for _, task := range tasks { + if task.ServiceID == service.ID { + extended.State = task.Status.State + extended.Status = task.Status.Message + break + } + } + + servicesWithTasks = append(servicesWithTasks, extended) + } + + return servicesWithTasks +} + func fetchFromSock(socketPath string, path string, target any) error { client := &http.Client{ Timeout: 5 * time.Second, @@ -282,12 +435,3 @@ func fetchFromSock(socketPath string, path string, target any) error { return nil } - -func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) { - var containers []dockerContainerJsonResponse - if err := fetchFromSock(socketPath, "http://docker/containers/json?all=true", &containers); err != nil { - return nil, err - } - - return containers, nil -} diff --git a/internal/glance/widget-swarm-services.go b/internal/glance/widget-swarm-services.go deleted file mode 100644 index 0b9b35d1..00000000 --- a/internal/glance/widget-swarm-services.go +++ /dev/null @@ -1,183 +0,0 @@ -package glance - -import ( - "fmt" - "strings" -) - -type swarmServiceJsonResponse struct { - ID string `json:"ID"` - Spec swarmServiceSpec `json:"Spec"` -} - -type swarmTaskJsonResponse struct { - ID string `json:"ID"` - ServiceID string `json:"ServiceID"` - Status swarmTaskStatus `json:"Status"` -} - -type swarmServiceSpec struct { - Name string `json:"Name"` - Labels dockerContainerLabels `json:"Labels"` - TaskTemplate swarmTaskTemplate `json:"TaskTemplate"` -} - -type swarmTaskTemplate struct { - ContainerSpec swarmContainerSpec `json:"ContainerSpec"` -} - -type swarmContainerSpec struct { - Image string `json:"Image"` -} - -type swarmTaskStatus struct { - State string `json:"State"` - Message string `json:"Message"` -} - -type swarmExtendedService struct { - *swarmServiceJsonResponse `json:"Service"` - State string `json:"State"` - Status string `json:"Status"` -} - -func fetchSwarmServices(socketPath string, hideByDefault bool) (dockerContainerList, error) { - svcs, err := fetchAllSwarmServicesFromSock(socketPath) - if err != nil { - return nil, fmt.Errorf("fetching services: %w", err) - } - - tasks, err := fetchAllSwarmTasksFromSock(socketPath) - if err != nil { - return nil, fmt.Errorf("fetching tasks: %w", err) - } - - services := groupSwarmServiceTasks(svcs, tasks) - services, children := groupSwarmServiceChildren(services, hideByDefault) - - swarmServices := make(dockerContainerList, 0, len(services)) - - for i := range services { - service := &services[i] - - dc := dockerContainer{ - Title: deriveDockerContainerTitle(&service.Spec.Labels, service.Spec.Name), - URL: service.Spec.Labels.getOrDefault(dockerContainerLabelURL, ""), - Description: service.Spec.Labels.getOrDefault(dockerContainerLabelDescription, ""), - SameTab: stringToBool(service.Spec.Labels.getOrDefault(dockerContainerLabelSameTab, "false")), - Image: service.Spec.TaskTemplate.ContainerSpec.Image, - State: strings.ToLower(service.State), - StateText: strings.ToLower(service.Status), - Icon: newCustomIconField(service.Spec.Labels.getOrDefault(dockerContainerLabelIcon, "si:docker")), - } - - if idValue := service.Spec.Labels.getOrDefault(dockerContainerLabelID, ""); idValue != "" { - if children, ok := children[idValue]; ok { - for i := range children { - child := &children[i] - dc.Children = append(dc.Children, dockerContainer{ - Title: deriveDockerContainerTitle(&child.Spec.Labels, child.Spec.Name), - StateText: child.Status, - StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)), - }) - } - } - } - - dc.Children.sortByStateIconThenTitle() - - stateIconSupersededByChild := false - for i := range dc.Children { - if dc.Children[i].StateIcon == dockerContainerStateIconWarn { - dc.StateIcon = dockerContainerStateIconWarn - stateIconSupersededByChild = true - break - } - } - if !stateIconSupersededByChild { - dc.StateIcon = dockerContainerStateToStateIcon(dc.State) - } - - swarmServices = append(swarmServices, dc) - } - - return swarmServices, nil -} - -func groupSwarmServiceChildren( - services []swarmExtendedService, - hideByDefault bool, -) ( - []swarmExtendedService, - map[string][]swarmExtendedService, -) { - parents := make([]swarmExtendedService, 0, len(services)) - children := make(map[string][]swarmExtendedService) - - for i := range services { - service := &services[i] - - if isDockerContainerHidden(&service.Spec.Labels, hideByDefault) { - continue - } - - isParent := service.Spec.Labels.getOrDefault(dockerContainerLabelID, "") != "" - parent := service.Spec.Labels.getOrDefault(dockerContainerLabelParent, "") - - if !isParent && parent != "" { - children[parent] = append(children[parent], *service) - } else { - parents = append(parents, *service) - } - } - - return parents, children -} - -func groupSwarmServiceTasks( - services []swarmServiceJsonResponse, - tasks []swarmTaskJsonResponse, -) []swarmExtendedService { - servicesWithTasks := make([]swarmExtendedService, 0, len(services)) - - for i := range services { - service := &services[i] - extended := swarmExtendedService{service, "unknown", "no tasks found"} - - for _, task := range tasks { - // TODO: Is there a better way to "prioritize" running tasks over shutdown? - if task.Status.State == "running" { - extended.State = task.Status.State - extended.Status = task.Status.Message - break - } - - if task.ServiceID == service.ID { - extended.State = task.Status.State - extended.Status = task.Status.Message - } - } - - servicesWithTasks = append(servicesWithTasks, extended) - } - - return servicesWithTasks -} - -func fetchAllSwarmServicesFromSock(socketPath string) ([]swarmServiceJsonResponse, error) { - var services []swarmServiceJsonResponse - if err := fetchFromSock(socketPath, "http://docker/services", &services); err != nil { - return nil, err - } - - return services, nil -} - -func fetchAllSwarmTasksFromSock(socketPath string) ([]swarmTaskJsonResponse, error) { - var tasks []swarmTaskJsonResponse - if err := fetchFromSock(socketPath, "http://docker/tasks", &tasks); err != nil { - return nil, err - } - - return tasks, nil -} From 4018363afa2a359338229f393958369f03b9daff Mon Sep 17 00:00:00 2001 From: kuhree Date: Tue, 25 Mar 2025 16:12:24 -0400 Subject: [PATCH 06/10] chore: updates docs, swarm-mode -> mode --- docs/configuration.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 3e7cea94..cc472b7f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1826,7 +1826,7 @@ If any of the child containers are down, their status will propagate up to the p | ---- | ---- | -------- | ------- | | hide-by-default | boolean | no | false | | sock-path | string | no | /var/run/docker.sock | -| swarm-mode | boolean | no | false | +| mode | string | no | standalone | ##### `hide-by-default` Whether to hide the containers by default. If set to `true` you'll have to manually add a `glance.hide: false` label to each container you want to display. By default all containers will be shown and if you want to hide a specific container you can add a `glance.hide: true` label. @@ -1834,8 +1834,11 @@ Whether to hide the containers by default. If set to `true` you'll have to manua ##### `sock-path` The path to the Docker socket. -##### `swarm-mode` -Whether to enable Docker's swarm mode. If set to `true` swarm services are fetched instead of docker containers. +##### `mode` +The mode to fetch containers. + +- `standalone` (default) fetches all containers on the node +- `swarm` - fetches containers by swarm service on the node #### Labels | Name | Description | From d9633502273a2eedde1dc4ec3ec12ff32349f382 Mon Sep 17 00:00:00 2001 From: kuhree Date: Tue, 25 Mar 2025 16:12:35 -0400 Subject: [PATCH 07/10] fix: adds mode validation --- internal/glance/widget-docker-containers.go | 27 ++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/internal/glance/widget-docker-containers.go b/internal/glance/widget-docker-containers.go index 256e4c0b..bcab4895 100644 --- a/internal/glance/widget-docker-containers.go +++ b/internal/glance/widget-docker-containers.go @@ -29,6 +29,21 @@ func (widget *dockerContainersWidget) initialize() error { widget.SockPath = "/var/run/docker.sock" } + widget.Mode = strings.ToLower(widget.Mode) + if widget.Mode == "" { + widget.Mode = dockerContainerModeStandalone + } else if !dockerContainerValidModes[widget.Mode] { + validModes := make([]string, 0, len(dockerContainerValidModes)) + for key := range dockerContainerValidModes { + validModes = append(validModes, key) + } + + return fmt.Errorf( + "invalid mode: %q, must be one of %q", + widget.Mode, strings.Join(validModes, ","), + ) + } + return nil } @@ -46,6 +61,16 @@ func (widget *dockerContainersWidget) Render() template.HTML { return widget.renderTemplate(widget, dockerContainersWidgetTemplate) } +const ( + dockerContainerModeStandalone = "standalone" + dockerContainerModeSwarm = "swarm" +) + +var dockerContainerValidModes = map[string]bool{ + dockerContainerModeStandalone: true, + dockerContainerModeSwarm: true, +} + const ( dockerContainerLabelHide = "glance.hide" dockerContainerLabelName = "glance.name" @@ -319,7 +344,7 @@ func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefau func fetchContainersByMode(socketPath string, mode string) ([]dockerContainerJsonResponse, error) { switch mode { - case "swarm": + case dockerContainerModeSwarm: return getSwarmContainers(socketPath) default: return getDockerContainers(socketPath) From 135d05be3613e0151cef5593d0d45f6400cafc43 Mon Sep 17 00:00:00 2001 From: kuhree Date: Tue, 25 Mar 2025 17:04:58 -0400 Subject: [PATCH 08/10] fix: child container's title --- internal/glance/widget-docker-containers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/glance/widget-docker-containers.go b/internal/glance/widget-docker-containers.go index bcab4895..470bc789 100644 --- a/internal/glance/widget-docker-containers.go +++ b/internal/glance/widget-docker-containers.go @@ -268,7 +268,7 @@ func fetchDockerContainers(socketPath string, hideByDefault bool, mode string) ( for i := range children { child := &children[i] dc.Children = append(dc.Children, dockerContainer{ - Title: deriveDockerContainerTitle(container), + Title: deriveDockerContainerTitle(child), StateText: child.Status, StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)), }) From 03d163188e60b1992b6ad7cefd3772dfaf055cac Mon Sep 17 00:00:00 2001 From: kuhree Date: Wed, 26 Mar 2025 14:23:45 -0400 Subject: [PATCH 09/10] fix: docker swarm task sorting - fix: error messages - refactor: uses map for latest tasks - fix: task sorting --- internal/glance/widget-docker-containers.go | 50 +++++++++++++-------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/internal/glance/widget-docker-containers.go b/internal/glance/widget-docker-containers.go index 470bc789..5cf08a61 100644 --- a/internal/glance/widget-docker-containers.go +++ b/internal/glance/widget-docker-containers.go @@ -140,19 +140,27 @@ type swarmServiceJsonResponse struct { type swarmTaskJsonResponse struct { ID string `json:"ID"` + ServiceID string `json:"ServiceID"` CreatedAt string `json:"CreatedAt"` UpdatedAt string `json:"UpdatedAt"` - ServiceID string `json:"ServiceID"` Status struct { - State string `json:"State"` - Message string `json:"Message"` + Timestamp string `json:"Timestamp"` + State string `json:"State"` + Message string `json:"Message"` } `json:"Status"` } type swarmTaskJsonList []swarmTaskJsonResponse -func (tasks swarmTaskJsonList) sortByUpdatedThenCreatedAt() { +func (tasks swarmTaskJsonList) sortByStatusUpdatedThenCreated() { sort.SliceStable(tasks, func(a, b int) bool { + timestampA, errA := time.Parse(time.RFC3339, tasks[a].Status.Timestamp) + timestampB, errB := time.Parse(time.RFC3339, tasks[b].Status.Timestamp) + + if errA == nil && errB == nil { + return timestampA.After(timestampB) + } + updatedA, errA := time.Parse(time.RFC3339, tasks[a].UpdatedAt) updatedB, errB := time.Parse(time.RFC3339, tasks[b].UpdatedAt) @@ -183,7 +191,7 @@ func (services swarmExtendedServiceList) toContainerJsonList() []dockerContainer containers := make([]dockerContainerJsonResponse, 0, len(services)) for i := range services { - service := services[i] + service := &services[i] container := dockerContainerJsonResponse{ Names: []string{service.Spec.Name}, Image: service.Spec.TaskTemplate.ContainerSpec.Image, @@ -354,16 +362,14 @@ func fetchContainersByMode(socketPath string, mode string) ([]dockerContainerJso func getSwarmContainers(socketPath string) ([]dockerContainerJsonResponse, error) { svcs, err := fetchAllSwarmServicesFromSock(socketPath) if err != nil { - return nil, fmt.Errorf("fetching services: %w", err) + return nil, fmt.Errorf("fetching swarm services: %w", err) } tasks, err := fetchAllSwarmTasksFromSock(socketPath) if err != nil { - return nil, fmt.Errorf("fetching tasks: %w", err) + return nil, fmt.Errorf("fetching swarm tasks: %w", err) } - tasks.sortByUpdatedThenCreatedAt() - services := extendSwarmServices(svcs, tasks) containers := services.toContainerJsonList() return containers, nil @@ -372,7 +378,7 @@ func getSwarmContainers(socketPath string) ([]dockerContainerJsonResponse, error func getDockerContainers(socketPath string) ([]dockerContainerJsonResponse, error) { containers, err := fetchAllDockerContainersFromSock(socketPath) if err != nil { - return nil, fmt.Errorf("fetching swarm containers: %w", err) + return nil, fmt.Errorf("fetching docker containers: %w", err) } return containers, nil @@ -409,24 +415,30 @@ func extendSwarmServices( services []swarmServiceJsonResponse, tasks swarmTaskJsonList, ) swarmExtendedServiceList { - servicesWithTasks := make([]swarmExtendedService, 0, len(services)) + tasks.sortByStatusUpdatedThenCreated() + latestTaskByServiceID := make(map[string]*swarmTaskJsonResponse) + for i := range tasks { + task := &tasks[i] + _, exists := latestTaskByServiceID[task.ServiceID] + if !exists { + latestTaskByServiceID[task.ServiceID] = task + } + } + servicesExtended := make([]swarmExtendedService, 0, len(services)) for i := range services { service := &services[i] extended := swarmExtendedService{service, "unknown", "no tasks found"} - for _, task := range tasks { - if task.ServiceID == service.ID { - extended.State = task.Status.State - extended.Status = task.Status.Message - break - } + if latestTask, exists := latestTaskByServiceID[service.ID]; exists { + extended.State = latestTask.Status.State + extended.Status = latestTask.Status.Message } - servicesWithTasks = append(servicesWithTasks, extended) + servicesExtended = append(servicesExtended, extended) } - return servicesWithTasks + return servicesExtended } func fetchFromSock(socketPath string, path string, target any) error { From 7ce54ed8cf0cc4d0cf99647e568ab14997ec9601 Mon Sep 17 00:00:00 2001 From: kuhree Date: Fri, 28 Mar 2025 12:41:30 -0400 Subject: [PATCH 10/10] fix: determines service status based on replicas / global tasks --- internal/glance/widget-docker-containers.go | 220 +++++++++++++++++--- 1 file changed, 195 insertions(+), 25 deletions(-) diff --git a/internal/glance/widget-docker-containers.go b/internal/glance/widget-docker-containers.go index 5cf08a61..53222a82 100644 --- a/internal/glance/widget-docker-containers.go +++ b/internal/glance/widget-docker-containers.go @@ -125,9 +125,30 @@ func (l *dockerContainerLabels) getOrDefault(label, def string) string { return v } +type swarmNodeJsonResponse struct { + ID string `json:"ID"` + CreatedAt string `json:"CreatedAt"` + UpdatedAt string `json:"UpdatedAt"` + Spec struct { + Role string `json:"Role"` + Availability string `json:"Availability"` + } `json:"Spec"` + Status struct { + State string `json:"State"` + Addr string `json:"Addr"` + } `json:"Status"` + ManagerStatus struct { + Leader bool `json:"Leader"` + Reachability string `json:"Reachability"` + Addr string `json:"Addr"` + } `json:"ManagerStatus"` +} + type swarmServiceJsonResponse struct { - ID string `json:"ID"` - Spec struct { + ID string `json:"ID"` + CreatedAt string `json:"CreatedAt"` + UpdatedAt string `json:"UpdatedAt"` + Spec struct { Name string `json:"Name"` Labels dockerContainerLabels `json:"Labels"` TaskTemplate struct { @@ -135,15 +156,23 @@ type swarmServiceJsonResponse struct { Image string `json:"Image"` } `json:"ContainerSpec"` } `json:"TaskTemplate"` + Mode struct { + Global *struct{} `json:"Global,omitempty"` + Replicated *struct { + Replicas int `json:"Replicas"` + } `json:"Replicated,omitempty"` + } `json:"Mode"` } `json:"Spec"` } type swarmTaskJsonResponse struct { - ID string `json:"ID"` - ServiceID string `json:"ServiceID"` - CreatedAt string `json:"CreatedAt"` - UpdatedAt string `json:"UpdatedAt"` - Status struct { + ID string `json:"ID"` + ServiceID string `json:"ServiceID"` + CreatedAt string `json:"CreatedAt"` + UpdatedAt string `json:"UpdatedAt"` + DesiredState string `json:"DesiredState"` + NodeID string `json:"NodeID"` + Status struct { Timestamp string `json:"Timestamp"` State string `json:"State"` Message string `json:"Message"` @@ -239,9 +268,9 @@ func dockerContainerStateToStateIcon(state string) string { return dockerContainerStateIconOK case "paused": return dockerContainerStateIconPaused - case "new", "pending", "assigned", "accepted", "ready", "preparing", "starting": + case "pending": return dockerContainerStateIconPending - case "exited", "unhealthy", "dead", "failed", "shutdown", "rejected", "orphaned", "remove": + case "exited", "unhealthy", "dead": return dockerContainerStateIconWarn default: return dockerContainerStateIconOther @@ -365,12 +394,17 @@ func getSwarmContainers(socketPath string) ([]dockerContainerJsonResponse, error return nil, fmt.Errorf("fetching swarm services: %w", err) } + nodes, err := fetchAllSwarmNodesFromSock(socketPath) + if err != nil { + return nil, fmt.Errorf("fetching swarm nodes: %w", err) + } + tasks, err := fetchAllSwarmTasksFromSock(socketPath) if err != nil { return nil, fmt.Errorf("fetching swarm tasks: %w", err) } - services := extendSwarmServices(svcs, tasks) + services := extendSwarmServices(svcs, nodes, tasks) containers := services.toContainerJsonList() return containers, nil } @@ -393,6 +427,15 @@ func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonR return containers, nil } +func fetchAllSwarmNodesFromSock(socketPath string) ([]swarmNodeJsonResponse, error) { + var nodes []swarmNodeJsonResponse + if err := fetchFromSock(socketPath, "http://docker/nodes", &nodes); err != nil { + return nil, err + } + + return nodes, nil +} + func fetchAllSwarmServicesFromSock(socketPath string) ([]swarmServiceJsonResponse, error) { var services []swarmServiceJsonResponse if err := fetchFromSock(socketPath, "http://docker/services", &services); err != nil { @@ -413,35 +456,162 @@ func fetchAllSwarmTasksFromSock(socketPath string) (swarmTaskJsonList, error) { func extendSwarmServices( services []swarmServiceJsonResponse, + nodes []swarmNodeJsonResponse, tasks swarmTaskJsonList, ) swarmExtendedServiceList { tasks.sortByStatusUpdatedThenCreated() - latestTaskByServiceID := make(map[string]*swarmTaskJsonResponse) - for i := range tasks { - task := &tasks[i] - _, exists := latestTaskByServiceID[task.ServiceID] - if !exists { - latestTaskByServiceID[task.ServiceID] = task - } + tasksByService := make(map[string][]swarmTaskJsonResponse) + for _, task := range tasks { + tasksByService[task.ServiceID] = append(tasksByService[task.ServiceID], task) } - servicesExtended := make([]swarmExtendedService, 0, len(services)) + servicesExtended := make(swarmExtendedServiceList, 0, len(services)) for i := range services { service := &services[i] - extended := swarmExtendedService{service, "unknown", "no tasks found"} + serviceTasks := tasksByService[service.ID] + state, statusMsg := deriveServiceStatus(service, nodes, serviceTasks) + servicesExtended = append(servicesExtended, swarmExtendedService{ + swarmServiceJsonResponse: service, + State: state, + Status: statusMsg, + }) + } + + return servicesExtended +} + +func deriveServiceStatus( + service *swarmServiceJsonResponse, + nodes []swarmNodeJsonResponse, + tasks []swarmTaskJsonResponse, +) (string, string) { + expectedReplicas, isGlobal := calculateExpectedReplicas(service, nodes) + taskStateCounts, tasksForErrorMsg := aggregateTaskStates(tasks) + totalTasks := len(tasks) + + if totalTasks == 0 && expectedReplicas > 0 { + return "pending", "Service created but no tasks are assigned yet" + } else if expectedReplicas == 0 { + tasksUp := taskStateCounts["running"] + taskStateCounts["pending"] + if tasksUp > 0 { + return "pending", fmt.Sprintf("Service scaling down (%d tasks terminating)", tasksUp) + } + + return "complete", "Service scaled to 0 replicas" + } else if complete := taskStateCounts["complete"]; complete == expectedReplicas { + return "complete", fmt.Sprintf("Service tasks completed (%d tasks)", complete) + } else if running := taskStateCounts["running"]; running == expectedReplicas { + statusMsg := fmt.Sprintf("Service running (%d/%d replicas)", running, expectedReplicas) + if isGlobal { + statusMsg = fmt.Sprintf("Service running globally (%d/%d nodes)", running, expectedReplicas) + } + + return "running", statusMsg + } else if pending := taskStateCounts["pending"]; pending > 0 { + statusMsg := fmt.Sprintf("Service pending (%d/%d replicas running, %d pending)", running, expectedReplicas, pending) + if isGlobal { + statusMsg = fmt.Sprintf("Service pending globally (%d/%d nodes running, %d pending)", running, expectedReplicas, pending) + } + + return "pending", statusMsg + } else if unhealthy := taskStateCounts["unhealthy"]; unhealthy > 0 { + state := "unhealthy" + statusMsg := fmt.Sprintf("Service degraded (%d tasks unhealthy)", unhealthy) + if len(tasksForErrorMsg) > 0 { + details := combineTaskStatusMessages(tasksForErrorMsg, 3) + if details != "" { + statusMsg += ": " + details + } + } - if latestTask, exists := latestTaskByServiceID[service.ID]; exists { - extended.State = latestTask.Status.State - extended.Status = latestTask.Status.Message + if taskStateCounts["running"] > 0 { + state = "warn" + statusMsg = fmt.Sprintf("%s, %d/%d healthy", statusMsg, taskStateCounts["running"], expectedReplicas) } - servicesExtended = append(servicesExtended, extended) + return state, statusMsg + } else if other := taskStateCounts["other"]; other > 0 { + return "other", fmt.Sprintf("Service in an unknown state (%d tasks in unexpected states)", other) } - return servicesExtended + return "other", "Service in an uknown state" +} + +func calculateExpectedReplicas( + service *swarmServiceJsonResponse, + nodes []swarmNodeJsonResponse, +) (int, bool) { + expectedReplicas := 0 + isGlobal := service.Spec.Mode.Global != nil + + if isGlobal { + availableNodes := 0 + for _, node := range nodes { + if node.Status.State == "ready" && node.Spec.Availability == "active" { + availableNodes++ + } + } + + expectedReplicas = availableNodes + } else if service.Spec.Mode.Replicated != nil && service.Spec.Mode.Replicated.Replicas > 0 { + expectedReplicas = service.Spec.Mode.Replicated.Replicas + } + + return expectedReplicas, isGlobal +} + +func aggregateTaskStates( + tasks []swarmTaskJsonResponse, +) (map[string]int, []swarmTaskJsonResponse) { + tasksForErrorMsg := make([]swarmTaskJsonResponse, 0) + taskStateCounts := make(map[string]int, 0) + for _, task := range tasks { + if task.DesiredState == task.Status.State { + taskStateCounts[task.Status.State]++ + } else { + switch task.Status.State { + case "running", "new", "pending", "assigned", "accepted", "preparing", "starting", "ready": + taskStateCounts["pending"]++ + case "complete", "failed", "rejected", "shutdown", "orphaned", "remove": + taskStateCounts["unhealthy"]++ + tasksForErrorMsg = append(tasksForErrorMsg, task) + default: + taskStateCounts["other"]++ + } + } + } + + return taskStateCounts, tasksForErrorMsg +} + +func combineTaskStatusMessages(tasks []swarmTaskJsonResponse, maxMessages int) string { + if len(tasks) == 0 || maxMessages <= 0 { + return "" + } + + messageCount := min(len(tasks), maxMessages) + messages := make([]string, 0, messageCount) + + for i := range messageCount { + taskMessage := tasks[i].Status.Message + if taskMessage != "" { + id, _ := limitStringLength(tasks[i].ID, 12) + messages = append(messages, fmt.Sprintf("Task %s: %s", id, taskMessage)) + } + } + + if len(messages) == 0 { + return "" + } + + if len(tasks) > maxMessages { + messages = append(messages, fmt.Sprintf("...and %d more tasks", len(tasks)-maxMessages)) + } + + return strings.Join(messages, "; ") } -func fetchFromSock(socketPath string, path string, target any) error { +func fetchFromSock[T any](socketPath string, path string, target T) error { client := &http.Client{ Timeout: 5 * time.Second, Transport: &http.Transport{