Update label overrides implementation

This commit is contained in:
Svilen Markov 2025-04-26 17:39:05 +01:00
parent 51e70347e4
commit bba2c5b20c
3 changed files with 142 additions and 109 deletions

View File

@ -1701,11 +1701,8 @@ Display the status of your Docker containers along with an icon and an optional
```yaml ```yaml
- type: docker-containers - type: docker-containers
hide-by-default: false 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] > [!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: > 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 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 ```yaml
- type: docker-containers - type: docker-containers
hide-by-default: false containers:
readable-names: false container_name_1:
containers: # Alternative to using docker labels title: Container Name
container_name_1: # This is the actual container name description: Description of the container
title: "Test Container Name" url: https://container.domain.com
description: "test-description" icon: si:container-icon
url: "127.0.0.1:3011/test"
icon: "si:jellyfin"
hide: false 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 | | Name | Type | Required | Default |
| ---- | ---- | -------- | ------- | | ---- | ---- | -------- | ------- |
| hide-by-default | boolean | no | false | | hide-by-default | boolean | no | false |
| format-container-names | boolean | no | false |
| sock-path | string | no | /var/run/docker.sock | | sock-path | string | no | /var/run/docker.sock |
##### `hide-by-default` ##### `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. 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` ##### `sock-path`
The path to the Docker socket. The path to the Docker socket.

View File

@ -14,7 +14,7 @@
{{- range .Children }} {{- range .Children }}
<li class="flex gap-7 items-center"> <li class="flex gap-7 items-center">
<div class="margin-bottom-3">{{ template "state-icon" .StateIcon }}</div> <div class="margin-bottom-3">{{ template "state-icon" .StateIcon }}</div>
<div class="color-highlight">{{ .Title }} <span class="size-h5 color-base">{{ .StateText }}</span></div> <div class="color-highlight">{{ .Name }} <span class="size-h5 color-base">{{ .StateText }}</span></div>
</li> </li>
{{- end }} {{- end }}
</ul> </ul>
@ -24,9 +24,9 @@
<div class="min-width-0"> <div class="min-width-0">
{{- if .URL }} {{- if .URL }}
<a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Title }}</a> <a href="{{ .URL | safeURL }}" class="color-highlight size-title-dynamic block text-truncate" {{ if not .SameTab }}target="_blank"{{ end }} rel="noreferrer">{{ .Name }}</a>
{{- else }} {{- else }}
<div class="color-highlight text-truncate size-title-dynamic">{{ .Title }}</div> <div class="color-highlight text-truncate size-title-dynamic">{{ .Name }}</div>
{{- end }} {{- end }}
{{- if .Description }} {{- if .Description }}
<div class="text-truncate">{{ .Description }}</div> <div class="text-truncate">{{ .Description }}</div>

View File

@ -7,6 +7,7 @@ import (
"html/template" "html/template"
"net" "net"
"net/http" "net/http"
"net/url"
"sort" "sort"
"strings" "strings"
"time" "time"
@ -17,10 +18,12 @@ var dockerContainersWidgetTemplate = mustParseTemplate("docker-containers.html",
type dockerContainersWidget struct { type dockerContainersWidget struct {
widgetBase `yaml:",inline"` widgetBase `yaml:",inline"`
HideByDefault bool `yaml:"hide-by-default"` HideByDefault bool `yaml:"hide-by-default"`
RunningOnly bool `yaml:"running-only"`
Category string `yaml:"category"`
SockPath string `yaml:"sock-path"` SockPath string `yaml:"sock-path"`
ReadableNames bool `yaml:"readable-names"` FormatContainerNames bool `yaml:"format-container-names"`
Containers dockerContainerList `yaml:"-"` Containers dockerContainerList `yaml:"-"`
ContainerMap map[string]dockerContainerConfig `yaml:"containers,omitempty"` LabelOverrides map[string]map[string]string `yaml:"containers"`
} }
func (widget *dockerContainersWidget) initialize() error { func (widget *dockerContainersWidget) initialize() error {
@ -34,7 +37,14 @@ func (widget *dockerContainersWidget) initialize() error {
} }
func (widget *dockerContainersWidget) update(ctx context.Context) { 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) { if !widget.canContinueUpdateAfterHandlingErr(err) {
return return
} }
@ -56,6 +66,7 @@ const (
dockerContainerLabelIcon = "glance.icon" dockerContainerLabelIcon = "glance.icon"
dockerContainerLabelID = "glance.id" dockerContainerLabelID = "glance.id"
dockerContainerLabelParent = "glance.parent" dockerContainerLabelParent = "glance.parent"
dockerContainerLabelCategory = "glance.category"
) )
const ( const (
@ -100,7 +111,7 @@ func (l *dockerContainerLabels) getOrDefault(label, def string) string {
} }
type dockerContainer struct { type dockerContainer struct {
Title string Name string
URL string URL string
SameTab bool SameTab bool
Image string Image string
@ -112,11 +123,6 @@ type dockerContainer struct {
Children dockerContainerList Children dockerContainerList
} }
type dockerContainerConfig struct {
dockerContainer `yaml:",inline"`
Hide bool `yaml:"hide,omitempty"`
}
type dockerContainerList []dockerContainer type dockerContainerList []dockerContainer
func (containers dockerContainerList) sortByStateIconThenTitle() { func (containers dockerContainerList) sortByStateIconThenTitle() {
@ -127,7 +133,7 @@ func (containers dockerContainerList) sortByStateIconThenTitle() {
return (*p)[containers[a].StateIcon] < (*p)[containers[b].StateIcon] 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 { func fetchDockerContainers(
name = strings.NewReplacer("-", " ", "_", " ").Replace(name) socketPath string,
words := strings.Fields(name) hideByDefault bool,
for i, word := range words { category string,
words[i] = strings.Title(word) runningOnly bool,
} formatNames bool,
return strings.Join(words, " ") labelOverrides map[string]map[string]string,
} ) (dockerContainerList, error) {
containers, err := fetchDockerContainersFromSource(socketPath, category, runningOnly, labelOverrides)
func fetchDockerContainers(socketPath string, hideByDefault bool, readableNames bool, containerOverrides map[string]dockerContainerConfig) (dockerContainerList, error) {
containers, err := fetchAllDockerContainersFromSock(socketPath)
if err != nil { if err != nil {
return nil, fmt.Errorf("fetching containers: %w", err) return nil, fmt.Errorf("fetching containers: %w", err)
} }
@ -165,48 +169,15 @@ func fetchDockerContainers(socketPath string, hideByDefault bool, readableNames
for i := range containers { for i := range containers {
container := &containers[i] container := &containers[i]
containerName := ""
if len(container.Names) > 0 {
containerName = strings.TrimLeft(container.Names[0], "/")
}
dc := dockerContainer{ dc := dockerContainer{
Name: deriveDockerContainerName(container, formatNames),
URL: container.Labels.getOrDefault(dockerContainerLabelURL, ""),
Description: container.Labels.getOrDefault(dockerContainerLabelDescription, ""),
SameTab: stringToBool(container.Labels.getOrDefault(dockerContainerLabelSameTab, "false")),
Image: container.Image, Image: container.Image,
State: strings.ToLower(container.State), State: strings.ToLower(container.State),
StateText: strings.ToLower(container.Status), StateText: strings.ToLower(container.Status),
SameTab: stringToBool(container.Labels.getOrDefault(dockerContainerLabelSameTab, "false")), Icon: newCustomIconField(container.Labels.getOrDefault(dockerContainerLabelIcon, "si:docker")),
}
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"))
} }
if idValue := container.Labels.getOrDefault(dockerContainerLabelID, ""); idValue != "" { if idValue := container.Labels.getOrDefault(dockerContainerLabelID, ""); idValue != "" {
@ -214,7 +185,7 @@ func fetchDockerContainers(socketPath string, hideByDefault bool, readableNames
for i := range children { for i := range children {
child := &children[i] child := &children[i]
dc.Children = append(dc.Children, dockerContainer{ dc.Children = append(dc.Children, dockerContainer{
Title: deriveDockerContainerTitle(child), Name: deriveDockerContainerName(child, formatNames),
StateText: child.Status, StateText: child.Status,
StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)), StateIcon: dockerContainerStateToStateIcon(strings.ToLower(child.State)),
}) })
@ -242,12 +213,31 @@ func fetchDockerContainers(socketPath string, hideByDefault bool, readableNames
return dockerContainers, nil return dockerContainers, nil
} }
func deriveDockerContainerTitle(container *dockerContainerJsonResponse) string { func deriveDockerContainerName(container *dockerContainerJsonResponse, formatNames bool) string {
if v := container.Labels.getOrDefault(dockerContainerLabelName, ""); v != "" { if v := container.Labels.getOrDefault(dockerContainerLabelName, ""); v != "" {
return 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( func groupDockerContainerChildren(
@ -288,17 +278,44 @@ func isDockerContainerHidden(container *dockerContainerJsonResponse, hideByDefau
return hideByDefault return hideByDefault
} }
func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonResponse, error) { func fetchDockerContainersFromSource(
client := &http.Client{ source string,
Timeout: 5 * time.Second, 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{ Transport: &http.Transport{
DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
return net.Dial("unix", socketPath) 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 { if err != nil {
return nil, fmt.Errorf("creating request: %w", err) 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 nil, fmt.Errorf("decoding response: %w", err)
} }
for i := range containers {
container := &containers[i]
name := strings.TrimLeft(itemAtIndexOrDefault(container.Names, 0, ""), "/")
if name == "" {
continue
}
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
}
}
// 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 return containers, nil
} }
func (widget *dockerContainersWidget) GetContainerNames() ([]string, error) {
containers, err := fetchAllDockerContainersFromSock(widget.SockPath)
if err != nil {
return nil, fmt.Errorf("fetching containers: %w", err)
}
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)
}
}
}
sort.Strings(names)
return names, nil
}