瀏覽代碼

Update docker containers widget

Svilen Markov 7 月之前
父節點
當前提交
fcda017c39

+ 1 - 1
.gitignore

@@ -1,5 +1,5 @@
 /assets
 /assets
 /build
 /build
 /playground
 /playground
+/.idea
 glance*.yml
 glance*.yml
-/.idea

+ 0 - 16
Dockerfile.debug

@@ -1,16 +0,0 @@
-FROM golang:1.23.1-alpine3.20 AS builder
-
-WORKDIR /app
-COPY . /app
-
-RUN go install github.com/go-delve/delve/cmd/dlv@latest
-RUN CGO_ENABLED=0 go build -gcflags="all=-N -l" .
-
-FROM alpine:3.20
-
-WORKDIR /app
-COPY --from=builder /app/glance .
-COPY --from=builder /go/bin/dlv .
-
-EXPOSE 2345/tcp 8080/tcp
-CMD ["./dlv", "--listen=:2345", "--headless=true", "--api-version=2", "--accept-multiclient", "exec", "./glance"]

+ 1 - 1
README.md

@@ -20,9 +20,9 @@
 * Twitch channels & top games
 * Twitch channels & top games
 * GitHub releases
 * GitHub releases
 * Repository overview
 * Repository overview
+* Docker containers
 * Site monitor
 * Site monitor
 * Search box
 * Search box
-* Docker
 
 
 #### Themeable
 #### Themeable
 ![multiple color schemes example](docs/images/themes-example.png)
 ![multiple color schemes example](docs/images/themes-example.png)

+ 2 - 1
docs/configuration.md

@@ -1795,7 +1795,8 @@ Example:
 
 
 Note the use of `|` after `source:`, this allows you to insert a multi-line string.
 Note the use of `|` after `source:`, this allows you to insert a multi-line string.
 
 
-### Docker
+### Docker Containers
+<!-- TODO: update -->
 The Docker widget allows you to monitor your Docker containers.
 The Docker widget allows you to monitor your Docker containers.
 To enable this feature, ensure that your setup provides access to the **docker.sock** file (also you may use a TCP connection).
 To enable this feature, ensure that your setup provides access to the **docker.sock** file (also you may use a TCP connection).
 
 

+ 0 - 81
internal/feed/docker.go

@@ -1,81 +0,0 @@
-package feed
-
-import (
-	"context"
-	"encoding/json"
-	"errors"
-	"fmt"
-	"net"
-	"net/http"
-	"net/url"
-	"strings"
-	"time"
-)
-
-type DockerContainer struct {
-	Id     string
-	Image  string
-	Names  []string
-	Status string
-	State  string
-	Labels map[string]string
-}
-
-func FetchDockerContainers(URL string) ([]DockerContainer, error) {
-	hostURL, err := parseHostURL(URL)
-	if err != nil {
-		return nil, err
-	}
-
-	transport := &http.Transport{
-		MaxIdleConns:    6,
-		IdleConnTimeout: 30 * time.Second,
-		DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
-			return net.Dial(hostURL.Scheme, hostURL.Host)
-		},
-	}
-
-	cli := &http.Client{
-		Transport:     transport,
-		CheckRedirect: checkRedirect,
-	}
-
-	resp, err := cli.Get("http://docker/containers/json")
-	if err != nil {
-		return nil, err
-	}
-	defer resp.Body.Close()
-
-	var results []DockerContainer
-	err = json.NewDecoder(resp.Body).Decode(&results)
-	return results, err
-}
-
-func parseHostURL(host string) (*url.URL, error) {
-	proto, addr, ok := strings.Cut(host, "://")
-	if !ok || addr == "" {
-		return nil, fmt.Errorf("unable to parse docker host: %s", host)
-	}
-
-	var basePath string
-	if proto == "tcp" {
-		parsed, err := url.Parse(host)
-		if err != nil {
-			return nil, err
-		}
-		addr = parsed.Host
-		basePath = parsed.Path
-	}
-	return &url.URL{
-		Scheme: proto,
-		Host:   addr,
-		Path:   basePath,
-	}, nil
-}
-
-func checkRedirect(_ *http.Request, via []*http.Request) error {
-	if via[0].Method == http.MethodGet {
-		return http.ErrUseLastResponse
-	}
-	return errors.New("unexpected redirect in response")
-}

+ 13 - 14
internal/glance/config-fields.go

@@ -180,22 +180,19 @@ type customIconField struct {
 	// invert the color based on the theme being light or dark
 	// invert the color based on the theme being light or dark
 }
 }
 
 
-func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
-	var value string
-	if err := node.Decode(&value); err != nil {
-		return err
-	}
+func newCustomIconField(value string) customIconField {
+	field := customIconField{}
 
 
 	prefix, icon, found := strings.Cut(value, ":")
 	prefix, icon, found := strings.Cut(value, ":")
 	if !found {
 	if !found {
-		i.URL = url
-		return nil
+		field.URL = value
+		return field
 	}
 	}
 
 
 	switch prefix {
 	switch prefix {
 	case "si":
 	case "si":
-		i.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
-		i.IsFlatIcon = true
+		field.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
+		field.IsFlatIcon = true
 	case "di":
 	case "di":
 		// syntax: di:<icon_name>[.svg|.png]
 		// syntax: di:<icon_name>[.svg|.png]
 		// if the icon name is specified without extension, it is assumed to be wanting the SVG icon
 		// if the icon name is specified without extension, it is assumed to be wanting the SVG icon
@@ -211,18 +208,20 @@ func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
 			ext = "svg"
 			ext = "svg"
 		}
 		}
 
 
-		i.URL = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/" + ext + "/" + basename + "." + ext
+		field.URL = "https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/" + ext + "/" + basename + "." + ext
 	default:
 	default:
-		i.URL = url
+		field.URL = value
 	}
 	}
 
 
-	return nil
+	return field
 }
 }
 
 
-func (i *CustomIcon) UnmarshalYAML(node *yaml.Node) error {
+func (i *customIconField) UnmarshalYAML(node *yaml.Node) error {
 	var value string
 	var value string
 	if err := node.Decode(&value); err != nil {
 	if err := node.Decode(&value); err != nil {
 		return err
 		return err
 	}
 	}
-	return i.FromURL(value)
+
+	*i = newCustomIconField(value)
+	return nil
 }
 }

+ 28 - 31
internal/glance/static/main.css

@@ -1390,6 +1390,33 @@ details[open] .summary::after {
     flex-shrink: 0;
     flex-shrink: 0;
 }
 }
 
 
+.docker-container-icon {
+    display: block;
+    filter: grayscale(0.4);
+    object-fit: contain;
+    aspect-ratio: 1 / 1;
+    width: 2.7rem;
+    opacity: 0.8;
+    transition: filter 0.3s, opacity 0.3s;
+}
+
+.docker-container-icon.flat-icon {
+    opacity: 0.7;
+}
+
+.docker-container:hover .docker-container-icon {
+    opacity: 1;
+}
+
+.docker-container:hover .docker-container-icon:not(.flat-icon) {
+    filter: grayscale(0);
+}
+
+.docker-container-status-icon {
+    width: 2rem;
+    height: 2rem;
+}
+
 .thumbnail {
 .thumbnail {
     filter: grayscale(0.2) contrast(0.9);
     filter: grayscale(0.2) contrast(0.9);
     opacity: 0.8;
     opacity: 0.8;
@@ -1540,37 +1567,6 @@ details[open] .summary::after {
     background: linear-gradient(0deg, var(--color-widget-background) 10%, transparent);
     background: linear-gradient(0deg, var(--color-widget-background) 10%, transparent);
 }
 }
 
 
-.docker-container-icon {
-    display: block;
-    opacity: 0.8;
-    filter: grayscale(0.4);
-    object-fit: contain;
-    aspect-ratio: 1 / 1;
-    width: 3.2rem;
-    position: relative;
-    top: -0.1rem;
-    transition: filter 0.3s, opacity 0.3s;
-}
-
-.docker-container-icon.simple-icon {
-    opacity: 0.7;
-}
-
-.docker-container:hover .docker-container-icon {
-    opacity: 1;
-}
-
-.docker-container:hover .docker-container-icon:not(.simple-icon) {
-    filter: grayscale(0);
-}
-
-.docker-container-status-icon {
-    flex-shrink: 0;
-    margin-left: auto;
-    width: 2rem;
-    height: 2rem;
-}
-
 @media (max-width: 1190px) {
 @media (max-width: 1190px) {
     .header-container {
     .header-container {
         display: none;
         display: none;
@@ -1837,6 +1833,7 @@ details[open] .summary::after {
 .gap-35             { gap: 3.5rem; }
 .gap-35             { gap: 3.5rem; }
 .gap-45             { gap: 4.5rem; }
 .gap-45             { gap: 4.5rem; }
 .gap-55             { gap: 5.5rem; }
 .gap-55             { gap: 5.5rem; }
+.margin-left-auto   { margin-left: auto; }
 .margin-top-3       { margin-top: 0.3rem; }
 .margin-top-3       { margin-top: 0.3rem; }
 .margin-top-5       { margin-top: 0.5rem; }
 .margin-top-5       { margin-top: 0.5rem; }
 .margin-top-7       { margin-top: 0.7rem; }
 .margin-top-7       { margin-top: 0.7rem; }

+ 65 - 0
internal/glance/templates/docker-containers.html

@@ -0,0 +1,65 @@
+{{ template "widget-base.html" . }}
+
+{{ define "widget-content" }}
+<div class="dynamic-columns list-gap-24 list-with-separator">
+    {{ range .Containers }}
+    <div class="docker-container flex items-center gap-15">
+        <div class="shrink-0" data-popover-type="html" data-popover-position="above" data-popover-offset="0.25" data-popover-margin="0.1rem">
+            <img class="docker-container-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
+            <div data-popover-html>
+                <div class="color-highlight text-truncate block">{{ .Image }}</div>
+                <div>{{ .StateText }}</div>
+                {{ if .Children }}
+                <ul class="list list-gap-4 margin-top-10">
+                    {{ range .Children }}
+                    <li class="flex gap-7 items-center">
+                        <div class="margin-bottom-3">{{ template "state-icon" .StateIcon }}</div>
+                        <div class="color-highlight">{{ .Title }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
+                    </li>
+                    {{ end }}
+                </ul>
+                {{ end }}
+                <div class="margin-top-10">created <span {{ .Created | dynamicRelativeTimeAttrs }}></span> ago</div>
+            </div>
+        </div>
+
+        <div class="min-width-0">
+            {{ if .URL }}
+            <a href="{{ .URL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a>
+            {{ else }}
+            <div class="color-highlight text-truncate size-title-dynamic">{{ .Title }}</div>
+            {{ end }}
+            {{ if .Description }}
+            <div class="text-truncate">{{ .Description }}</div>
+            {{ end }}
+        </div>
+
+        <div class="margin-left-auto shrink-0" data-popover-type="text" data-popover-position="above" data-popover-text="{{ .State }}">
+        {{ template "state-icon" .StateIcon }}
+        </div>
+    </div>
+    {{ else }}
+    <div class="text-center">No containers available to show.</div>
+    {{ end }}
+</div>
+{{ end }}
+
+{{ define "state-icon" }}
+{{ if eq . "ok" }}
+<svg class="docker-container-status-icon" fill="var(--color-positive)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
+    <path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
+</svg>
+{{ else if eq . "warn" }}
+<svg class="docker-container-status-icon" fill="var(--color-negative)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
+    <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495ZM10 5a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 10 5Zm0 9a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
+</svg>
+{{ else if eq . "paused" }}
+<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
+    <path fill-rule="evenodd" d="M2 10a8 8 0 1 1 16 0 8 8 0 0 1-16 0Zm5-2.25A.75.75 0 0 1 7.75 7h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Zm4 0a.75.75 0 0 1 .75-.75h.5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-.75.75h-.5a.75.75 0 0 1-.75-.75v-4.5Z" clip-rule="evenodd" />
+</svg>
+{{ else }}
+<svg class="docker-container-status-icon" fill="var(--color-text-base)" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20">
+    <path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0ZM8.94 6.94a.75.75 0 1 1-1.061-1.061 3 3 0 1 1 2.871 5.026v.345a.75.75 0 0 1-1.5 0v-.5c0-.72.57-1.172 1.081-1.287A1.5 1.5 0 1 0 8.94 6.94ZM10 15a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
+</svg>
+{{ end }}
+{{ end }}

+ 0 - 44
internal/glance/templates/docker.html

@@ -1,44 +0,0 @@
-{{ template "widget-base.html" . }}
-
-{{ define "widget-content" }}
-<div class="dynamic-columns list-gap-20 list-with-separator">
-    {{ range .Containers }}
-    <div class="docker-container flex items-center gap-15">
-        {{ template "container" . }}
-    </div>
-    {{ end }}
-</div>
-{{ end }}
-
-{{ define "container" }}
-{{ if .Icon.URL }}
-<img class="monitor-site-icon{{ if .Icon.IsFlatIcon }} flat-icon{{ end }}" src="{{ .Icon.URL }}" alt="" loading="lazy">
-{{ end }}
-<div class="min-width-0">
-    <a class="size-h3 color-highlight text-truncate block" href="{{ .URL }}" target="_blank" rel="noreferrer" title="{{ .Title }}">{{ .Title }}</a>
-    <div class="text-truncate" title="{{ .Image }}">{{ .Image }}</div>
-    <ul class="size-h6 color-subdue list-horizontal-text">
-        <li>{{ .StatusShort }}</li>
-        <li>{{ .StatusFull }}</li>
-    </ul>
-</div>
-{{ if eq .StatusStyle "success" }}
-<div class="docker-container-status-icon">
-    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
-        <path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" clip-rule="evenodd" />
-    </svg>
-</div>
-{{ else if eq .StatusStyle "warning" }}
-<div class="docker-container-status-icon">
-    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-positive)">
-        <path fill-rule="evenodd" d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" clip-rule="evenodd" />
-    </svg>
-</div>
-{{ else }}
-<div class="docker-container-status-icon">
-    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="var(--color-negative)">
-        <path fill-rule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75Zm0 8.25a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"  clip-rule="evenodd" />
-    </svg>
-</div>
-{{ end }}
-{{ end }}

+ 12 - 0
internal/glance/utils.go

@@ -166,3 +166,15 @@ func executeTemplateToHTML(t *template.Template, data interface{}) (template.HTM
 
 
 	return template.HTML(b.String()), nil
 	return template.HTML(b.String()), nil
 }
 }
+
+func stringToBool(s string) bool {
+	return s == "true" || s == "yes"
+}
+
+func itemAtIndexOrDefault[T any](items []T, index int, def T) T {
+	if index >= len(items) {
+		return def
+	}
+
+	return items[index]
+}

+ 275 - 0
internal/glance/widget-docker-containers.go

@@ -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
+}

+ 1 - 1
internal/glance/widget.go

@@ -69,7 +69,7 @@ func newWidget(widgetType string) (widget, error) {
 		w = &splitColumnWidget{}
 		w = &splitColumnWidget{}
 	case "custom-api":
 	case "custom-api":
 		w = &customAPIWidget{}
 		w = &customAPIWidget{}
-	case "docker":
+	case "docker-containers":
 		w = &dockerContainersWidget{}
 		w = &dockerContainersWidget{}
 	default:
 	default:
 		return nil, fmt.Errorf("unknown widget type: %s", widgetType)
 		return nil, fmt.Errorf("unknown widget type: %s", widgetType)

+ 0 - 106
internal/widget/docker.go

@@ -1,106 +0,0 @@
-package widget
-
-import (
-	"context"
-	"golang.org/x/text/cases"
-	"golang.org/x/text/language"
-	"html/template"
-	"strings"
-	"time"
-
-	"github.com/glanceapp/glance/internal/assets"
-	"github.com/glanceapp/glance/internal/feed"
-)
-
-const (
-	defaultDockerHost   = "unix:///var/run/docker.sock"
-	dockerGlanceEnable  = "glance.enable"
-	dockerGlanceTitle   = "glance.title"
-	dockerGlanceUrl     = "glance.url"
-	dockerGlanceIconUrl = "glance.iconUrl"
-)
-
-type containerData struct {
-	Id          string
-	Image       string
-	URL         string
-	Title       string
-	Icon        CustomIcon
-	StatusShort string
-	StatusFull  string
-	StatusStyle string
-}
-
-type Docker struct {
-	widgetBase `yaml:",inline"`
-	HostURL    string          `yaml:"host-url"`
-	Containers []containerData `yaml:"-"`
-}
-
-func (widget *Docker) Initialize() error {
-	widget.withTitle("Docker").withCacheDuration(1 * time.Minute)
-	return nil
-}
-
-func (widget *Docker) Update(_ context.Context) {
-	if widget.HostURL == "" {
-		widget.HostURL = defaultDockerHost
-	}
-
-	containers, err := feed.FetchDockerContainers(widget.HostURL)
-
-	if !widget.canContinueUpdateAfterHandlingErr(err) {
-		return
-	}
-
-	var items []containerData
-	for _, c := range containers {
-		isGlanceEnabled := getLabelValue(c.Labels, dockerGlanceEnable, "true")
-
-		if isGlanceEnabled != "true" {
-			continue
-		}
-
-		var item containerData
-		item.Id = c.Id
-		item.Image = c.Image
-		item.Title = getLabelValue(c.Labels, dockerGlanceTitle, strings.Join(c.Names, ""))
-		item.URL = getLabelValue(c.Labels, dockerGlanceUrl, "")
-
-		_ = item.Icon.FromURL(getLabelValue(c.Labels, dockerGlanceIconUrl, "si:docker"))
-
-		switch c.State {
-		case "paused":
-		case "starting":
-		case "unhealthy":
-			item.StatusStyle = "warning"
-			break
-		case "stopped":
-		case "dead":
-		case "exited":
-			item.StatusStyle = "error"
-			break
-		default:
-			item.StatusStyle = "success"
-		}
-
-		item.StatusFull = c.Status
-		item.StatusShort = cases.Title(language.English, cases.Compact).String(c.State)
-
-		items = append(items, item)
-	}
-
-	widget.Containers = items
-}
-
-func (widget *Docker) Render() template.HTML {
-	return widget.render(widget, assets.DockerTemplate)
-}
-
-// getLabelValue get string value associated to a label.
-func getLabelValue(labels map[string]string, labelName, defaultValue string) string {
-	if value, ok := labels[labelName]; ok && len(value) > 0 {
-		return value
-	}
-	return defaultValue
-}