diff --git a/.gitignore b/.gitignore index f7e0f6c..e466992 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /assets /build /playground +/.idea glance*.yml diff --git a/README.md b/README.md index c6193a9..da6fb58 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ * Twitch channels & top games * GitHub releases * Repository overview +* Docker containers * Site monitor * Search box diff --git a/docs/configuration.md b/docs/configuration.md index 15d9eac..6ef4b87 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -32,6 +32,7 @@ - [Twitch Top Games](#twitch-top-games) - [iframe](#iframe) - [HTML](#html) + - [Docker](#docker) ## Intro @@ -1793,3 +1794,75 @@ Example: ``` Note the use of `|` after `source:`, this allows you to insert a multi-line string. + +### 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). + +Add the following to your `docker-compose` or `docker run` command to enable the Docker widget: + +**Docker Example:** +```bash +docker run -d -p 8080:8080 \ + -v ./glance.yml:/app/glance.yml \ + -v /etc/timezone:/etc/timezone:ro \ + -v /etc/localtime:/etc/localtime:ro \ + -v /var/run/docker.sock:/var/run/docker.sock:ro \ + glanceapp/glance +``` + +**Docker Compose Example:** +```yaml +services: + glance: + image: glanceapp/glance + volumes: + - ./glance.yml:/app/glance.yml + - /etc/timezone:/etc/timezone:ro + - /etc/localtime:/etc/localtime:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + ports: + - 8080:8080 + restart: unless-stopped +``` + +#### Configuration +To integrate the Docker widget into your dashboard, include the following snippet in your `glance.yml` file: + +```yaml +- type: docker + host-url: tcp://localhost:2375 + cache: 1m +``` + +#### Properties + +| Name | Type | Required | Default | +| ---- | ---- | -------- | ------- | +| host-url | string | no | `unix:///var/run/docker.sock` | + +#### Leveraging Container Labels +You can use container labels to control visibility, URLs, icons, and titles within the Docker widget. Add the following labels to your container configuration for enhanced customization: + +```yaml +labels: + - "glance.enable=true" # Enable or disable visibility of the container (default: true) + - "glance.title=Glance" # Optional friendly name (defaults to container name) + - "glance.url=https://app.example.com" # Optional URL associated with the container + - "glance.iconUrl=si:docker" # Optional URL to an image which will be used as the icon for the site + +``` + +**Default Values:** + +| Name | Default | +|----------------|------------| +| glance.enable | true | +| glance.title | Container name | +| glance.url | (none) | +| glance.iconUrl | si:docker | + +Preview: + +![](images/docker-widget-preview.png) diff --git a/docs/images/docker-widget-preview.png b/docs/images/docker-widget-preview.png new file mode 100644 index 0000000..5b644d4 Binary files /dev/null and b/docs/images/docker-widget-preview.png differ diff --git a/go.sum b/go.sum index 03a2b53..aeab91d 100644 --- a/go.sum +++ b/go.sum @@ -75,4 +75,4 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= \ No newline at end of file diff --git a/internal/glance/config-fields.go b/internal/glance/config-fields.go index 527dfe2..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 = value - 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,10 +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 = value + field.URL = value } + return field +} + +func (i *customIconField) UnmarshalYAML(node *yaml.Node) error { + var value string + if err := node.Decode(&value); err != nil { + return err + } + + *i = newCustomIconField(value) return nil } diff --git a/internal/glance/static/js/popover.js b/internal/glance/static/js/popover.js index 533feed..26d1850 100644 --- a/internal/glance/static/js/popover.js +++ b/internal/glance/static/js/popover.js @@ -123,11 +123,11 @@ function repositionContainer() { } else if (left + containerBounds.width > window.innerWidth) { containerElement.style.removeProperty("left"); containerElement.style.right = 0; - containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + "px"); + containerElement.style.setProperty("--triangle-offset", containerBounds.width - containerInlinePadding - (window.innerWidth - targetBounds.left - targetBoundsWidthOffset) + -1 + "px"); } else { containerElement.style.removeProperty("right"); containerElement.style.left = left + "px"; - containerElement.style.setProperty("--triangle-offset", ((targetBounds.left + targetBoundsWidthOffset) - left - containerInlinePadding) + "px"); + containerElement.style.setProperty("--triangle-offset", ((targetBounds.left + targetBoundsWidthOffset) - left - containerInlinePadding) + -1 + "px"); } const distanceFromTarget = activeTarget.dataset.popoverMargin || defaultDistanceFromTarget; diff --git a/internal/glance/static/main.css b/internal/glance/static/main.css index 25b4669..f0b8bc6 100644 --- a/internal/glance/static/main.css +++ b/internal/glance/static/main.css @@ -723,6 +723,7 @@ details[open] .summary::after { justify-content: space-between; position: relative; margin-bottom: 1.8rem; + z-index: 1; } .widget-error-header::before { @@ -738,19 +739,11 @@ details[open] .summary::after { .widget-error-icon { width: 2.4rem; height: 2.4rem; - border: 0.2rem solid var(--color-negative); - border-radius: 50%; - text-align: center; - line-height: 2rem; flex-shrink: 0; + stroke: var(--color-negative); opacity: 0.6; } -.widget-error-icon::before { - content: '!'; - color: var(--color-text-highlight); -} - .widget-content { container-type: inline-size; container-name: widget; @@ -1397,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; @@ -1813,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/widget-base.html b/internal/glance/templates/widget-base.html index bdd30b9..5861b52 100644 --- a/internal/glance/templates/widget-base.html +++ b/internal/glance/templates/widget-base.html @@ -15,7 +15,9 @@ {{ else }}
ERROR
-
+ + +

{{ if .Error }}{{ .Error }}{{ else }}No error information provided{{ 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 81cb39c..7e8a618 100644 --- a/internal/glance/widget.go +++ b/internal/glance/widget.go @@ -69,6 +69,8 @@ func newWidget(widgetType string) (widget, error) { w = &splitColumnWidget{} case "custom-api": w = &customAPIWidget{} + case "docker-containers": + w = &dockerContainersWidget{} default: return nil, fmt.Errorf("unknown widget type: %s", widgetType) }