소스 검색

Update docker containers widget

Svilen Markov 7 달 전
부모
커밋
fcda017c39

+ 1 - 1
.gitignore

@@ -1,5 +1,5 @@
 /assets
 /build
 /playground
+/.idea
 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
 * GitHub releases
 * Repository overview
+* Docker containers
 * Site monitor
 * Search box
-* Docker
 
 #### Themeable
 ![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.
 
-### Docker
+### Docker Containers
+<!-- TODO: update -->
 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).
 

+ 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
 }
 
-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, ":")
 	if !found {
-		i.URL = url
-		return nil
+		field.URL = value
+		return field
 	}
 
 	switch prefix {
 	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":
 		// syntax: di:<icon_name>[.svg|.png]
 		// 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"
 		}
 
-		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:
-		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
 	if err := node.Decode(&value); err != nil {
 		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;
 }
 
+.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 {
     filter: grayscale(0.2) contrast(0.9);
     opacity: 0.8;
@@ -1540,37 +1567,6 @@ details[open] .summary::after {
     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) {
     .header-container {
         display: none;
@@ -1837,6 +1833,7 @@ details[open] .summary::after {
 .gap-35             { gap: 3.5rem; }
 .gap-45             { gap: 4.5rem; }
 .gap-55             { gap: 5.5rem; }
+.margin-left-auto   { margin-left: auto; }
 .margin-top-3       { margin-top: 0.3rem; }
 .margin-top-5       { margin-top: 0.5rem; }
 .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
 }
+
+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{}
 	case "custom-api":
 		w = &customAPIWidget{}
-	case "docker":
+	case "docker-containers":
 		w = &dockerContainersWidget{}
 	default:
 		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
-}