|
@@ -0,0 +1,275 @@
|
|
|
+package glance
|
|
|
+
|
|
|
+import (
|
|
|
+ "context"
|
|
|
+ "encoding/json"
|
|
|
+ "fmt"
|
|
|
+ "html/template"
|
|
|
+ "net"
|
|
|
+ "net/http"
|
|
|
+ "sort"
|
|
|
+ "strings"
|
|
|
+ "time"
|
|
|
+)
|
|
|
+
|
|
|
+var dockerContainersWidgetTemplate = mustParseTemplate("docker-containers.html", "widget-base.html")
|
|
|
+
|
|
|
+type dockerContainersWidget struct {
|
|
|
+ widgetBase `yaml:",inline"`
|
|
|
+ HideByDefault bool `yaml:"hide-by-default"`
|
|
|
+ SockPath string `yaml:"sock-path"`
|
|
|
+ Containers dockerContainerList `yaml:"-"`
|
|
|
+}
|
|
|
+
|
|
|
+func (widget *dockerContainersWidget) initialize() error {
|
|
|
+ widget.withTitle("Docker Containers").withCacheDuration(1 * time.Minute)
|
|
|
+
|
|
|
+ if widget.SockPath == "" {
|
|
|
+ widget.SockPath = "/var/run/docker.sock"
|
|
|
+ }
|
|
|
+
|
|
|
+ return nil
|
|
|
+}
|
|
|
+
|
|
|
+func (widget *dockerContainersWidget) update(ctx context.Context) {
|
|
|
+ containers, err := fetchDockerContainers(widget.SockPath, widget.HideByDefault)
|
|
|
+ if !widget.canContinueUpdateAfterHandlingErr(err) {
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ containers.sortByStateIconThenTitle()
|
|
|
+ widget.Containers = containers
|
|
|
+}
|
|
|
+
|
|
|
+func (widget *dockerContainersWidget) Render() template.HTML {
|
|
|
+ return widget.renderTemplate(widget, dockerContainersWidgetTemplate)
|
|
|
+}
|
|
|
+
|
|
|
+const (
|
|
|
+ dockerContainerLabelHide = "glance.hide"
|
|
|
+ dockerContainerLabelTitle = "glance.title"
|
|
|
+ dockerContainerLabelURL = "glance.url"
|
|
|
+ dockerContainerLabelDescription = "glance.description"
|
|
|
+ dockerContainerLabelSameTab = "glance.same-tab"
|
|
|
+ dockerContainerLabelIcon = "glance.icon"
|
|
|
+ dockerContainerLabelID = "glance.id"
|
|
|
+ dockerContainerLabelParent = "glance.parent"
|
|
|
+)
|
|
|
+
|
|
|
+const (
|
|
|
+ dockerContainerStateIconOK = "ok"
|
|
|
+ dockerContainerStateIconPaused = "paused"
|
|
|
+ dockerContainerStateIconWarn = "warn"
|
|
|
+ dockerContainerStateIconOther = "other"
|
|
|
+)
|
|
|
+
|
|
|
+var dockerContainerStateIconPriorities = map[string]int{
|
|
|
+ dockerContainerStateIconWarn: 0,
|
|
|
+ dockerContainerStateIconOther: 1,
|
|
|
+ dockerContainerStateIconPaused: 2,
|
|
|
+ dockerContainerStateIconOK: 3,
|
|
|
+}
|
|
|
+
|
|
|
+type dockerContainerJsonResponse struct {
|
|
|
+ Names []string `json:"Names"`
|
|
|
+ Image string `json:"Image"`
|
|
|
+ State string `json:"State"`
|
|
|
+ Status string `json:"Status"`
|
|
|
+ Labels dockerContainerLabels `json:"Labels"`
|
|
|
+ Created int64 `json:"Created"`
|
|
|
+}
|
|
|
+
|
|
|
+type dockerContainerLabels map[string]string
|
|
|
+
|
|
|
+func (l *dockerContainerLabels) 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 dockerContainer struct {
|
|
|
+ Title string
|
|
|
+ URL string
|
|
|
+ SameTab bool
|
|
|
+ Image string
|
|
|
+ State string
|
|
|
+ StateText string
|
|
|
+ StateIcon string
|
|
|
+ Description string
|
|
|
+ Icon customIconField
|
|
|
+ Children dockerContainerList
|
|
|
+ Created time.Time
|
|
|
+}
|
|
|
+
|
|
|
+type dockerContainerList []dockerContainer
|
|
|
+
|
|
|
+func (containers dockerContainerList) sortByStateIconThenTitle() {
|
|
|
+ sort.SliceStable(containers, func(a, b int) bool {
|
|
|
+ p := &dockerContainerStateIconPriorities
|
|
|
+ if containers[a].StateIcon != containers[b].StateIcon {
|
|
|
+ return (*p)[containers[a].StateIcon] < (*p)[containers[b].StateIcon]
|
|
|
+ }
|
|
|
+
|
|
|
+ return strings.ToLower(containers[a].Title) < strings.ToLower(containers[b].Title)
|
|
|
+ })
|
|
|
+}
|
|
|
+
|
|
|
+func dockerContainerStateToStateIcon(state string) string {
|
|
|
+ switch state {
|
|
|
+ case "running":
|
|
|
+ return dockerContainerStateIconOK
|
|
|
+ case "paused":
|
|
|
+ return dockerContainerStateIconPaused
|
|
|
+ case "exited", "unhealthy", "dead":
|
|
|
+ return dockerContainerStateIconWarn
|
|
|
+ default:
|
|
|
+ return dockerContainerStateIconOther
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContainerList, error) {
|
|
|
+ containers, err := fetchAllDockerContainersFromSock(socketPath)
|
|
|
+ if err != nil {
|
|
|
+ return nil, fmt.Errorf("fetching containers: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ containers, children := groupDockerContainerChildren(containers, hideByDefault)
|
|
|
+ dockerContainers := make(dockerContainerList, 0, len(containers))
|
|
|
+
|
|
|
+ for i := range containers {
|
|
|
+ container := &containers[i]
|
|
|
+
|
|
|
+ dc := dockerContainer{
|
|
|
+ Title: deriveDockerContainerTitle(container),
|
|
|
+ URL: container.Labels.getOrDefault(dockerContainerLabelURL, ""),
|
|
|
+ Description: container.Labels.getOrDefault(dockerContainerLabelDescription, ""),
|
|
|
+ SameTab: stringToBool(container.Labels.getOrDefault(dockerContainerLabelSameTab, "false")),
|
|
|
+ Image: container.Image,
|
|
|
+ State: strings.ToLower(container.State),
|
|
|
+ StateText: strings.ToLower(container.Status),
|
|
|
+ Icon: newCustomIconField(container.Labels.getOrDefault(dockerContainerLabelIcon, "si:docker")),
|
|
|
+ Created: time.Unix(container.Created, 0),
|
|
|
+ }
|
|
|
+
|
|
|
+ if idValue := container.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),
|
|
|
+ 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)
|
|
|
+ }
|
|
|
+
|
|
|
+ dockerContainers = append(dockerContainers, dc)
|
|
|
+ }
|
|
|
+
|
|
|
+ return dockerContainers, nil
|
|
|
+}
|
|
|
+
|
|
|
+func deriveDockerContainerTitle(container *dockerContainerJsonResponse) string {
|
|
|
+ if v := container.Labels.getOrDefault(dockerContainerLabelTitle, ""); v != "" {
|
|
|
+ return v
|
|
|
+ }
|
|
|
+
|
|
|
+ return strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, "n/a"), "/")
|
|
|
+}
|
|
|
+
|
|
|
+func groupDockerContainerChildren(
|
|
|
+ containers []dockerContainerJsonResponse,
|
|
|
+ hideByDefault bool,
|
|
|
+) (
|
|
|
+ []dockerContainerJsonResponse,
|
|
|
+ map[string][]dockerContainerJsonResponse,
|
|
|
+) {
|
|
|
+ parents := make([]dockerContainerJsonResponse, 0, len(containers))
|
|
|
+ children := make(map[string][]dockerContainerJsonResponse)
|
|
|
+
|
|
|
+ for i := range containers {
|
|
|
+ container := &containers[i]
|
|
|
+
|
|
|
+ if isDockerContainerHidden(container, hideByDefault) {
|
|
|
+ continue
|
|
|
+ }
|
|
|
+
|
|
|
+ isParent := container.Labels.getOrDefault(dockerContainerLabelID, "") != ""
|
|
|
+ parent := container.Labels.getOrDefault(dockerContainerLabelParent, "")
|
|
|
+
|
|
|
+ if !isParent && parent != "" {
|
|
|
+ children[parent] = append(children[parent], *container)
|
|
|
+ } else {
|
|
|
+ parents = append(parents, *container)
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return parents, children
|
|
|
+}
|
|
|
+
|
|
|
+func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefault bool) bool {
|
|
|
+ if v := container.Labels.getOrDefault(dockerContainerLabelHide, ""); v != "" {
|
|
|
+ return stringToBool(v)
|
|
|
+ }
|
|
|
+
|
|
|
+ return hideByDefault
|
|
|
+}
|
|
|
+
|
|
|
+func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) {
|
|
|
+ client := &http.Client{
|
|
|
+ Timeout: 3 * 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/containers/json?all=true", 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 containers []dockerContainerJsonResponse
|
|
|
+ if err := json.NewDecoder(response.Body).Decode(&containers); err != nil {
|
|
|
+ return nil, fmt.Errorf("decoding response: %w", err)
|
|
|
+ }
|
|
|
+
|
|
|
+ return containers, nil
|
|
|
+}
|