mirror of
https://github.com/glanceapp/glance.git
synced 2025-06-21 18:31:24 +02:00
Update docker containers widget
This commit is contained in:
parent
0c36925783
commit
fcda017c39
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,5 +1,5 @@
|
|||||||
/assets
|
/assets
|
||||||
/build
|
/build
|
||||||
/playground
|
/playground
|
||||||
glance*.yml
|
|
||||||
/.idea
|
/.idea
|
||||||
|
glance*.yml
|
||||||
|
@ -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"]
|
|
@ -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
|
||||||

|

|
||||||
|
@ -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).
|
||||||
|
|
||||||
|
@ -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")
|
|
||||||
}
|
|
@ -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 {
|
func newCustomIconField(value string) customIconField {
|
||||||
var value string
|
field := customIconField{}
|
||||||
if err := node.Decode(&value); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
prefix, icon, found := strings.Cut(value, ":")
|
prefix, icon, found := strings.Cut(value, ":")
|
||||||
if !found {
|
if !found {
|
||||||
i.URL = url
|
field.URL = value
|
||||||
return nil
|
return field
|
||||||
}
|
}
|
||||||
|
|
||||||
switch prefix {
|
switch prefix {
|
||||||
case "si":
|
case "si":
|
||||||
i.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
|
field.URL = "https://cdn.jsdelivr.net/npm/simple-icons@latest/icons/" + icon + ".svg"
|
||||||
i.IsFlatIcon = true
|
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
|
||||||
}
|
}
|
||||||
|
@ -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
internal/glance/templates/docker-containers.html
Normal file
65
internal/glance/templates/docker-containers.html
Normal file
@ -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 }}
|
@ -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 }}
|
|
@ -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
internal/glance/widget-docker-containers.go
Normal file
275
internal/glance/widget-docker-containers.go
Normal file
@ -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
|
||||||
|
}
|
@ -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)
|
||||||
|
@ -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
|
|
||||||
}
|
|
Loading…
x
Reference in New Issue
Block a user