From fcda017c393bf24c4168f1856ae2ccb6f41aa3ec Mon Sep 17 00:00:00 2001 From: Svilen Markov <7613769+svilenmarkov@users.noreply.github.com> Date: Tue, 3 Dec 2024 19:04:43 +0000 Subject: [PATCH] Update docker containers widget --- .gitignore | 2 +- Dockerfile.debug | 16 - README.md | 2 +- docs/configuration.md | 3 +- internal/feed/docker.go | 81 ------ internal/glance/config-fields.go | 27 +- internal/glance/static/main.css | 59 ++-- .../glance/templates/docker-containers.html | 65 +++++ internal/glance/templates/docker.html | 44 --- internal/glance/utils.go | 12 + internal/glance/widget-docker-containers.go | 275 ++++++++++++++++++ internal/glance/widget.go | 2 +- internal/widget/docker.go | 106 ------- 13 files changed, 398 insertions(+), 296 deletions(-) delete mode 100644 Dockerfile.debug delete mode 100644 internal/feed/docker.go create mode 100644 internal/glance/templates/docker-containers.html delete mode 100644 internal/glance/templates/docker.html create mode 100644 internal/glance/widget-docker-containers.go delete mode 100644 internal/widget/docker.go diff --git a/.gitignore b/.gitignore index 6f7bcc0..e466992 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /assets /build /playground +/.idea glance*.yml -/.idea \ No newline at end of file diff --git a/Dockerfile.debug b/Dockerfile.debug deleted file mode 100644 index 9ecff9d..0000000 --- a/Dockerfile.debug +++ /dev/null @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md index a198219..da6fb58 100644 --- a/README.md +++ b/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) diff --git a/docs/configuration.md b/docs/configuration.md index cb8d924..6ef4b87 100644 --- a/docs/configuration.md +++ b/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 + 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). diff --git a/internal/feed/docker.go b/internal/feed/docker.go deleted file mode 100644 index 9f8ad6a..0000000 --- a/internal/feed/docker.go +++ /dev/null @@ -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") -} diff --git a/internal/glance/config-fields.go b/internal/glance/config-fields.go index 130cfce..a757e73 100644 --- a/internal/glance/config-fields.go +++ b/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:[.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 } diff --git a/internal/glance/static/main.css b/internal/glance/static/main.css index 46b1efe..f0b8bc6 100644 --- a/internal/glance/static/main.css +++ b/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; } diff --git a/internal/glance/templates/docker-containers.html b/internal/glance/templates/docker-containers.html new file mode 100644 index 0000000..9b7bd24 --- /dev/null +++ b/internal/glance/templates/docker-containers.html @@ -0,0 +1,65 @@ +{{ template "widget-base.html" . }} + +{{ define "widget-content" }} +
+ {{ range .Containers }} +
+
+ +
+
{{ .Image }}
+
{{ .StateText }}
+ {{ if .Children }} +
    + {{ range .Children }} +
  • +
    {{ template "state-icon" .StateIcon }}
    +
    {{ .Title }} {{ .StateText }}
    +
  • + {{ end }} +
+ {{ end }} +
created ago
+
+
+ +
+ {{ if .URL }} + {{ .Title }} + {{ else }} +
{{ .Title }}
+ {{ end }} + {{ if .Description }} +
{{ .Description }}
+ {{ end }} +
+ +
+ {{ template "state-icon" .StateIcon }} +
+
+ {{ else }} +
No containers available to show.
+ {{ end }} +
+{{ end }} + +{{ define "state-icon" }} +{{ if eq . "ok" }} + + + +{{ else if eq . "warn" }} + + + +{{ else if eq . "paused" }} + + + +{{ else }} + + + +{{ end }} +{{ end }} diff --git a/internal/glance/templates/docker.html b/internal/glance/templates/docker.html deleted file mode 100644 index e623712..0000000 --- a/internal/glance/templates/docker.html +++ /dev/null @@ -1,44 +0,0 @@ -{{ template "widget-base.html" . }} - -{{ define "widget-content" }} -
- {{ range .Containers }} -
- {{ template "container" . }} -
- {{ end }} -
-{{ end }} - -{{ define "container" }} -{{ if .Icon.URL }} - -{{ end }} -
- {{ .Title }} -
{{ .Image }}
- -
-{{ if eq .StatusStyle "success" }} -
- - - -
-{{ else if eq .StatusStyle "warning" }} -
- - - -
-{{ else }} -
- - - -
-{{ end }} -{{ end }} diff --git a/internal/glance/utils.go b/internal/glance/utils.go index 9600031..105cd0d 100644 --- a/internal/glance/utils.go +++ b/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] +} diff --git a/internal/glance/widget-docker-containers.go b/internal/glance/widget-docker-containers.go new file mode 100644 index 0000000..5aeff65 --- /dev/null +++ b/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 +} diff --git a/internal/glance/widget.go b/internal/glance/widget.go index a5d756f..7e8a618 100644 --- a/internal/glance/widget.go +++ b/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) diff --git a/internal/widget/docker.go b/internal/widget/docker.go deleted file mode 100644 index a4b3ecc..0000000 --- a/internal/widget/docker.go +++ /dev/null @@ -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 -}