diff --git a/docs/configuration.md b/docs/configuration.md index 2ddc862..1f2ad89 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1701,11 +1701,8 @@ Display the status of your Docker containers along with an icon and an optional ```yaml - type: docker-containers hide-by-default: false - readable-names: false ``` -The `readable-names` will try to auto format your container names by capitalizing the first letter and converting `-` and `_` characters to spaces. - > [!NOTE] > > The widget requires access to `docker.sock`. If you're running Glance inside a container, this can be done by mounting the socket as a volume: @@ -1730,18 +1727,16 @@ Configuration of the containers is done via labels applied to each container: glance.description: Movies & shows ``` -Configuration of the containers can also be overridden using `glance.yml`. Containers are specified by their container names, these will take preference over any docker labels that are set: +Alternatively, you can also define the values within your `glance.yml` via the `containers` property, where the key is the container name and each value is the same as the labels but without the "glance." prefix: ```yaml - type: docker-containers - hide-by-default: false - readable-names: false - containers: # Alternative to using docker labels - container_name_1: # This is the actual container name - title: "Test Container Name" - description: "test-description" - url: "127.0.0.1:3011/test" - icon: "si:jellyfin" + containers: + container_name_1: + title: Container Name + description: Description of the container + url: https://container.domain.com + icon: si:container-icon hide: false ``` @@ -1796,11 +1791,15 @@ If any of the child containers are down, their status will propagate up to the p | Name | Type | Required | Default | | ---- | ---- | -------- | ------- | | hide-by-default | boolean | no | false | +| format-container-names | boolean | no | false | | sock-path | string | no | /var/run/docker.sock | ##### `hide-by-default` Whether to hide the containers by default. If set to `true` you'll have to manually add a `glance.hide: false` label to each container you want to display. By default all containers will be shown and if you want to hide a specific container you can add a `glance.hide: true` label. +##### `format-container-names` +When set to `true`, automatically converts container names such as `container_name_1` into `Container Name 1`. + ##### `sock-path` The path to the Docker socket. diff --git a/internal/glance/templates/docker-containers.html b/internal/glance/templates/docker-containers.html index 66c79fd..991d66d 100644 --- a/internal/glance/templates/docker-containers.html +++ b/internal/glance/templates/docker-containers.html @@ -14,7 +14,7 @@ {{- range .Children }}
  • {{ template "state-icon" .StateIcon }}
    -
    {{ .Title }} {{ .StateText }}
    +
    {{ .Name }} {{ .StateText }}
  • {{- end }} @@ -24,9 +24,9 @@
    {{- if .URL }} - {{ .Title }} + {{ .Name }} {{- else }} -
    {{ .Title }}
    +
    {{ .Name }}
    {{- end }} {{- if .Description }}
    {{ .Description }}
    diff --git a/internal/glance/widget-docker-containers.go b/internal/glance/widget-docker-containers.go index 4ba7725..61cb388 100644 --- a/internal/glance/widget-docker-containers.go +++ b/internal/glance/widget-docker-containers.go @@ -7,6 +7,7 @@ import ( "html/template" "net" "net/http" + "net/url" "sort" "strings" "time" @@ -15,12 +16,14 @@ import ( 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"` - ReadableNames bool `yaml:"readable-names"` - Containers dockerContainerList `yaml:"-"` - ContainerMap map[string]dockerContainerConfig `yaml:"containers,omitempty"` + widgetBase `yaml:",inline"` + HideByDefault bool `yaml:"hide-by-default"` + RunningOnly bool `yaml:"running-only"` + Category string `yaml:"category"` + SockPath string `yaml:"sock-path"` + FormatContainerNames bool `yaml:"format-container-names"` + Containers dockerContainerList `yaml:"-"` + LabelOverrides map[string]map[string]string `yaml:"containers"` } func (widget *dockerContainersWidget) initialize() error { @@ -34,7 +37,14 @@ func (widget *dockerContainersWidget) initialize() error { } func (widget *dockerContainersWidget) update(ctx context.Context) { - containers, err := fetchDockerContainers(widget.SockPath, widget.HideByDefault, widget.ReadableNames, widget.ContainerMap) + containers, err := fetchDockerContainers( + widget.SockPath, + widget.HideByDefault, + widget.Category, + widget.RunningOnly, + widget.FormatContainerNames, + widget.LabelOverrides, + ) if !widget.canContinueUpdateAfterHandlingErr(err) { return } @@ -56,6 +66,7 @@ const ( dockerContainerLabelIcon = "glance.icon" dockerContainerLabelID = "glance.id" dockerContainerLabelParent = "glance.parent" + dockerContainerLabelCategory = "glance.category" ) const ( @@ -100,7 +111,7 @@ func (l *dockerContainerLabels) getOrDefault(label, def string) string { } type dockerContainer struct { - Title string + Name string URL string SameTab bool Image string @@ -112,11 +123,6 @@ type dockerContainer struct { Children dockerContainerList } -type dockerContainerConfig struct { - dockerContainer `yaml:",inline"` - Hide bool `yaml:"hide,omitempty"` -} - type dockerContainerList []dockerContainer func (containers dockerContainerList) sortByStateIconThenTitle() { @@ -127,7 +133,7 @@ func (containers dockerContainerList) sortByStateIconThenTitle() { return (*p)[containers[a].StateIcon] < (*p)[containers[b].StateIcon] } - return strings.ToLower(containers[a].Title) < strings.ToLower(containers[b].Title) + return strings.ToLower(containers[a].Name) < strings.ToLower(containers[b].Name) }) } @@ -144,17 +150,15 @@ func dockerContainerStateToStateIcon(state string) string { } } -func formatReadableName(name string) string { - name = strings.NewReplacer("-", " ", "_", " ").Replace(name) - words := strings.Fields(name) - for i, word := range words { - words[i] = strings.Title(word) - } - return strings.Join(words, " ") -} - -func fetchDockerContainers(socketPath string, hideByDefault bool, readableNames bool, containerOverrides map[string]dockerContainerConfig) (dockerContainerList, error) { - containers, err := fetchAllDockerContainersFromSock(socketPath) +func fetchDockerContainers( + socketPath string, + hideByDefault bool, + category string, + runningOnly bool, + formatNames bool, + labelOverrides map[string]map[string]string, +) (dockerContainerList, error) { + containers, err := fetchDockerContainersFromSource(socketPath, category, runningOnly, labelOverrides) if err != nil { return nil, fmt.Errorf("fetching containers: %w", err) } @@ -165,48 +169,15 @@ func fetchDockerContainers(socketPath string, hideByDefault bool, readableNames for i := range containers { container := &containers[i] - containerName := "" - if len(container.Names) > 0 { - containerName = strings.TrimLeft(container.Names[0], "/") - } - dc := dockerContainer{ - Image: container.Image, - State: strings.ToLower(container.State), - StateText: strings.ToLower(container.Status), - SameTab: stringToBool(container.Labels.getOrDefault(dockerContainerLabelSameTab, "false")), - } - - if override, exists := containerOverrides[containerName]; exists { - if override.Hide { - continue - } - - if override.Title != "" { - dc.Title = override.Title - } else { - title := deriveDockerContainerTitle(container) - if readableNames { - title = formatReadableName(title) - } - dc.Title = title - } - dc.URL = override.URL - dc.Description = override.Description - if override.Icon != (customIconField{}) { - dc.Icon = override.Icon - } else { - dc.Icon = newCustomIconField(container.Labels.getOrDefault(dockerContainerLabelIcon, "si:docker")) - } - } else { - title := deriveDockerContainerTitle(container) - if readableNames { - title = formatReadableName(title) - } - dc.Title = title - dc.URL = container.Labels.getOrDefault(dockerContainerLabelURL, "") - dc.Description = container.Labels.getOrDefault(dockerContainerLabelDescription, "") - dc.Icon = newCustomIconField(container.Labels.getOrDefault(dockerContainerLabelIcon, "si:docker")) + Name: deriveDockerContainerName(container, formatNames), + 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")), } if idValue := container.Labels.getOrDefault(dockerContainerLabelID, ""); idValue != "" { @@ -214,7 +185,7 @@ func fetchDockerContainers(socketPath string, hideByDefault bool, readableNames for i := range children { child := &children[i] dc.Children = append(dc.Children, dockerContainer{ - Title: deriveDockerContainerTitle(child), + Name: deriveDockerContainerName(child, formatNames), StateText: child.Status, StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)), }) @@ -242,12 +213,31 @@ func fetchDockerContainers(socketPath string, hideByDefault bool, readableNames return dockerContainers, nil } -func deriveDockerContainerTitle(container *dockerContainerJsonResponse) string { +func deriveDockerContainerName(container *dockerContainerJsonResponse, formatNames bool) string { if v := container.Labels.getOrDefault(dockerContainerLabelName, ""); v != "" { return v } - return strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, "n/a"), "/") + if len(container.Names) == 0 || container.Names[0] == "" { + return "n/a" + } + + name := strings.TrimLeft(container.Names[0], "/") + + if formatNames { + name = strings.ReplaceAll(name, "_", " ") + name = strings.ReplaceAll(name, "-", " ") + + words := strings.Split(name, " ") + for i := range words { + if len(words[i]) > 0 { + words[i] = strings.ToUpper(words[i][:1]) + words[i][1:] + } + } + name = strings.Join(words, " ") + } + + return name } func groupDockerContainerChildren( @@ -288,17 +278,44 @@ func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefau return hideByDefault } -func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) { - client := &http.Client{ - Timeout: 5 * time.Second, - Transport: &http.Transport{ - DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { - return net.Dial("unix", socketPath) +func fetchDockerContainersFromSource( + source string, + category string, + runningOnly bool, + labelOverrides map[string]map[string]string, +) ([]dockerContainerJsonResponse, error) { + var hostname string + + var client *http.Client + if strings.HasPrefix(source, "tcp://") || strings.HasPrefix(source, "http://") { + client = &http.Client{} + parsed, err := url.Parse(source) + if err != nil { + return nil, fmt.Errorf("parsing URL: %w", err) + } + + port := parsed.Port() + if port == "" { + port = "80" + } + + hostname = parsed.Hostname() + ":" + port + } else { + hostname = "docker" + client = &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", source) + }, }, - }, + } } - request, err := http.NewRequest("GET", "http://docker/containers/json?all=true", nil) + fetchAll := ternary(runningOnly, "false", "true") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + request, err := http.NewRequestWithContext(ctx, "GET", "http://"+hostname+"/containers/json?all="+fetchAll, nil) if err != nil { return nil, fmt.Errorf("creating request: %w", err) } @@ -318,26 +335,43 @@ func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonR return nil, fmt.Errorf("decoding response: %w", err) } - return containers, nil -} + for i := range containers { + container := &containers[i] + name := strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, ""), "/") -func (widget *dockerContainersWidget) GetContainerNames() ([]string, error) { - containers, err := fetchAllDockerContainersFromSock(widget.SockPath) - if err != nil { - return nil, fmt.Errorf("fetching containers: %w", err) - } + if name == "" { + continue + } - names := make([]string, 0, len(containers)) - for _, container := range containers { - if !isDockerContainerHidden(&container, widget.HideByDefault) { - // Get the clean container name without the leading '/' - name := strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, ""), "/") - if name != "" { - names = append(names, name) - } + overrides, ok := labelOverrides[name] + if !ok { + continue + } + + if container.Labels == nil { + container.Labels = make(dockerContainerLabels) + } + + for label, value := range overrides { + container.Labels["glance."+label] = value } } - sort.Strings(names) - return names, nil + // We have to filter here instead of using the `filters` parameter of Docker's API + // because the user may define a category override within their config + if category != "" { + filtered := make([]dockerContainerJsonResponse, 0, len(containers)) + + for i := range containers { + container := &containers[i] + + if container.Labels.getOrDefault(dockerContainerLabelCategory, "") == category { + filtered = append(filtered, *container) + } + } + + containers = filtered + } + + return containers, nil }