From 62e9c32082f113ebaad14e1c12bccfbb4b5b1640 Mon Sep 17 00:00:00 2001 From: HECHT Axel Date: Sat, 4 Jan 2025 01:52:47 +0100 Subject: [PATCH 001/179] feat: theme switcher --- docs/configuration.md | 62 ++++++++-- internal/glance/config.go | 13 ++ internal/glance/glance.go | 21 +++- internal/glance/static/js/main.js | 120 +++++++++++++++++++ internal/glance/static/main.css | 103 +++++++++++++++- internal/glance/templates/document.html | 2 +- internal/glance/templates/page.html | 32 ++++- internal/glance/templates/theme-style.gotmpl | 21 +++- 8 files changed, 352 insertions(+), 22 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 6bd1bc6..cda2fd5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -228,25 +228,41 @@ Example: ```yaml theme: - background-color: 100 20 10 - primary-color: 40 90 40 - contrast-multiplier: 1.1 + background-color: 186 21 20 + contrast-multiplier: 1.2 + primary-color: 97 13 80 + + presets: + my-custom-dark-theme: + background-color: 229 19 23 + contrast-multiplier: 1.2 + primary-color: 222 74 74 + positive-color: 96 44 68 + negative-color: 359 68 71 + my-custom-light-theme: + light: true + background-color: 220 23 95 + contrast-multiplier: 1.0 + primary-color: 220 91 54 + positive-color: 109 58 40 + negative-color: 347 87 44 ``` ### Themes If you don't want to spend time configuring your own theme, there are [several available themes](themes.md) which you can simply copy the values for. ### Properties -| Name | Type | Required | Default | -| ---- | ---- | -------- | ------- | -| light | boolean | no | false | -| background-color | HSL | no | 240 8 9 | -| primary-color | HSL | no | 43 50 70 | -| positive-color | HSL | no | same as `primary-color` | -| negative-color | HSL | no | 0 70 70 | -| contrast-multiplier | number | no | 1 | -| text-saturation-multiplier | number | no | 1 | -| custom-css-file | string | no | | +| Name | Type | Required | Default | +| ---- |-------|----------| ------- | +| light | boolean | no | false | +| background-color | HSL | no | 240 8 9 | +| primary-color | HSL | no | 43 50 70 | +| positive-color | HSL | no | same as `primary-color` | +| negative-color | HSL | no | 0 70 70 | +| contrast-multiplier | number | no | 1 | +| text-saturation-multiplier | number | no | 1 | +| custom-css-file | string | no | | +| presets | array | no | | #### `light` Whether the scheme is light or dark. This does not change the background color, it inverts the text colors so that they look appropriately on a light background. @@ -279,6 +295,26 @@ theme: custom-css-file: /assets/my-style.css ``` +#### `presets` +Define theme presets that can be selected from a dropdown menu in the webpage. Example: +```yaml +theme: + presets: + my-custom-dark-theme: # This will be displayed in the dropdown menu to select this theme + background-color: 229 19 23 + contrast-multiplier: 1.2 + primary-color: 222 74 74 + positive-color: 96 44 68 + negative-color: 359 68 71 + my-custom-light-theme: # This will be displayed in the dropdown menu to select this theme + light: true + background-color: 220 23 95 + contrast-multiplier: 1.0 + primary-color: 220 91 54 + positive-color: 109 58 40 + negative-color: 347 87 44 +``` + > [!TIP] > > Because Glance uses a lot of utility classes it might be difficult to target some elements. To make it easier to style specific widgets, each widget has a `widget-type-{name}` class, so for example if you wanted to make the links inside just the RSS widget bigger you could use the following selector: diff --git a/internal/glance/config.go b/internal/glance/config.go index 0ab79af..f110470 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -17,6 +17,16 @@ import ( "gopkg.in/yaml.v3" ) +type CssProperties struct { + BackgroundColor *hslColorField `yaml:"background-color"` + PrimaryColor *hslColorField `yaml:"primary-color"` + PositiveColor *hslColorField `yaml:"positive-color"` + NegativeColor *hslColorField `yaml:"negative-color"` + Light bool `yaml:"light"` + ContrastMultiplier float32 `yaml:"contrast-multiplier"` + TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"` +} + type config struct { Server struct { Host string `yaml:"host"` @@ -31,6 +41,7 @@ type config struct { } `yaml:"document"` Theme struct { + // Todo : Find a way to use CssProperties struct to avoid duplicates BackgroundColor *hslColorField `yaml:"background-color"` PrimaryColor *hslColorField `yaml:"primary-color"` PositiveColor *hslColorField `yaml:"positive-color"` @@ -39,6 +50,8 @@ type config struct { ContrastMultiplier float32 `yaml:"contrast-multiplier"` TextSaturationMultiplier float32 `yaml:"text-saturation-multiplier"` CustomCSSFile string `yaml:"custom-css-file"` + + Presets map[string]CssProperties `yaml:"presets"` } `yaml:"theme"` Branding struct { diff --git a/internal/glance/glance.go b/internal/glance/glance.go index b1fcc37..d5c7400 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -3,6 +3,7 @@ package glance import ( "bytes" "context" + "encoding/json" "fmt" "html/template" "log" @@ -125,8 +126,9 @@ func (a *application) transformUserDefinedAssetPath(path string) string { } type pageTemplateData struct { - App *application - Page *page + App *application + Page *page + Presets string } func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) { @@ -137,9 +139,20 @@ func (a *application) handlePageRequest(w http.ResponseWriter, r *http.Request) return } + presets := a.Config.Theme.Presets + keys := make([]string, 0, len(presets)) + for key := range presets { + keys = append(keys, key) + } + presetsAsJSON, jsonErr := json.Marshal(presets) + if jsonErr != nil { + log.Fatalf("Erreur lors de la conversion en JSON : %v", jsonErr) + } + pageData := pageTemplateData{ - Page: page, - App: a, + App: a, + Page: page, + Presets: string(presetsAsJSON), } var responseBytes bytes.Buffer diff --git a/internal/glance/static/js/main.js b/internal/glance/static/js/main.js index 58a8c2d..b644676 100644 --- a/internal/glance/static/js/main.js +++ b/internal/glance/static/js/main.js @@ -2,6 +2,30 @@ import { setupPopovers } from './popover.js'; import { setupMasonries } from './masonry.js'; import { throttledDebounce, isElementVisible, openURLInNewTab } from './utils.js'; +document.addEventListener('DOMContentLoaded', () => { + const theme = localStorage.getItem('theme'); + + if (!theme) { + return; + } + + const html = document.querySelector('html'); + const jsonTheme = JSON.parse(theme); + if (jsonTheme.themeScheme === 'light') { + html.classList.remove('dark-scheme'); + html.classList.add('light-scheme'); + } else if (jsonTheme.themeScheme === 'dark') { + html.classList.add('dark-scheme'); + html.classList.remove('light-scheme'); + } + + html.classList.add(jsonTheme.theme); + document.querySelector('[name=color-scheme]').setAttribute('content', jsonTheme.themeScheme); + Array.from(document.querySelectorAll('.dropdown-button span')).forEach((button) => { + button.textContent = jsonTheme.theme; + }) +}) + async function fetchPageContent(pageData) { // TODO: handle non 200 status codes/time outs // TODO: add retries @@ -638,6 +662,101 @@ function setupTruncatedElementTitles() { } } +/** + * @typedef {Object} HslColorField + * @property {number} Hue + * @property {number} Saturation + * @property {number} Lightness + */ + +/** + * @typedef {Object} Theme + * @property {HslColorField} BackgroundColor + * @property {HslColorField} PrimaryColor + * @property {HslColorField} PositiveColor + * @property {HslColorField} NegativeColor + * @property {boolean} Light + * @property {number} ContrastMultiplier + * @property {number} TextSaturationMultiplier + */ + +/** + * @typedef {Record} ThemeCollection + */ +function setupThemeSwitcher() { + const presetsContainers = Array.from(document.querySelectorAll('.custom-presets')); + const userThemesKeys = Object.keys(userThemes); + + presetsContainers.forEach((presetsContainer) => { + userThemesKeys.forEach(preset => { + const presetElement = document.createElement('div'); + presetElement.className = 'theme-option'; + presetElement.setAttribute('data-theme', preset); + presetElement.setAttribute('data-scheme', userThemes[preset].Light ? 'light' : 'dark'); + presetElement.textContent = preset; + presetsContainer.appendChild(presetElement); + }); + }); + + const dropdownButtons = Array.from(document.querySelectorAll('.dropdown-button')); + const dropdownContents = Array.from(document.querySelectorAll('.dropdown-content')); + + dropdownButtons.forEach((dropdownButton) => { + dropdownButton.addEventListener('click', (e) => { + e.stopPropagation(); + dropdownContents.forEach((dropdownContent) => { + dropdownContent.classList.toggle('show'); + }); + dropdownButton.classList.toggle('active'); + }); + }); + + document.addEventListener('click', (e) => { + if (!e.target.closest('.theme-dropdown')) { + dropdownContents.forEach((dropdownContent) => { + dropdownContent.classList.remove('show'); + }); + dropdownButtons.forEach((dropdownButton) => { + dropdownButton.classList.remove('active'); + }); + } + }); + + document.querySelectorAll('.theme-option').forEach(option => { + option.addEventListener('click', () => { + const selectedTheme = option.getAttribute('data-theme'); + const selectedThemeScheme = option.getAttribute('data-scheme'); + const previousTheme = localStorage.getItem('theme'); + dropdownContents.forEach((dropdownContent) => { + dropdownContent.classList.remove('show'); + }); + dropdownButtons.forEach((dropdownButton) => { + const html = document.querySelector('html'); + if (previousTheme) { + html.classList.remove(JSON.parse(previousTheme).theme); + } + dropdownButton.classList.remove('active'); + dropdownButton.querySelector('span').textContent = option.textContent; + html.classList.add(selectedTheme); + + if (selectedThemeScheme === 'light') { + html.classList.remove('dark-scheme'); + html.classList.add('light-scheme'); + } else if (selectedThemeScheme === 'dark') { + html.classList.add('dark-scheme'); + html.classList.remove('light-scheme'); + } + + document.querySelector('[name=color-scheme]').setAttribute('content', selectedThemeScheme); + localStorage.setItem('theme', JSON.stringify({ + theme: selectedTheme, + themeScheme: selectedThemeScheme + })); + }); + }); + }); +} + async function setupPage() { const pageElement = document.getElementById("page"); const pageContentElement = document.getElementById("page-content"); @@ -646,6 +765,7 @@ async function setupPage() { pageContentElement.innerHTML = pageContent; try { + setupThemeSwitcher(); setupPopovers(); setupClocks() setupCarousels(); diff --git a/internal/glance/static/main.css b/internal/glance/static/main.css index 1d5c19a..1128963 100644 --- a/internal/glance/static/main.css +++ b/internal/glance/static/main.css @@ -55,6 +55,28 @@ --font-size-h6: 1.1rem; } +.dark { + --scheme: ; + --bgh: 240; + --bgs: 8%; + --bgl: 9%; + --bghs: var(--bgh), var(--bgs); + --cm: 1; + --tsm: 1; +} + +.light { + --scheme: 100% -; + --bgh: 240; + --bgs: 50%; + --bgl: 98%; + --bghs: var(--bgh), var(--bgs); + --cm: 1; + --tsm: 1; + --color-primary: hsl(43, 50%, 70%); +} + + .light-scheme { --scheme: 100% -; } @@ -1625,7 +1647,6 @@ details[open] .summary::after { padding: 15px var(--content-bounds-padding); display: flex; align-items: center; - overflow-x: auto; scrollbar-width: thin; gap: 2.5rem; } @@ -1872,3 +1893,83 @@ details[open] .summary::after { .list-gap-20 { --list-half-gap: 1rem; } .list-gap-24 { --list-half-gap: 1.2rem; } .list-gap-34 { --list-half-gap: 1.7rem; } + +/* +### Theme Dropdown ### +*/ +.theme-dropdown { + position: relative; + display: inline-block; + right: 0; +} + +.dropdown-button { + padding: 10px 15px; + background: var(--color-widget-background); + border: 1px solid var(--color-widget-content-border); + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + min-width: 150px; + transition: border-color .2s; + color: var(--color-text-highlight); +} + +.dropdown-button:hover { + border-color: var(--color-text-subdue); +} + +.dropdown-content { + display: none; + position: absolute; + top: 100%; + left: 0; + background: var(--color-widget-content-border); + min-width: 150px; + box-shadow: 0 2px 5px rgba(0,0,0,0.2); + border-radius: 4px; + z-index: 1000; +} + +.mobile-navigation-page-links .dropdown-content { + top: unset; + bottom: 38px; +} + +.dropdown-content.show { + display: block; +} + +.theme-option { + padding: 10px 15px; + cursor: pointer; + transition: background-color 0.2s; +} + +.theme-option:hover { + background-color: #f8f9fa; +} + +.separator { + height: 1px; + background-color: #dee2e6; + margin: 5px 0; +} + +.arrow { + border: solid #666; + border-width: 0 2px 2px 0; + display: inline-block; + padding: 3px; + transform: rotate(45deg); + transition: transform 0.2s ease; + margin-left: auto; + position: relative; + top: -1px; +} + +.dropdown-button.active .arrow { + transform: rotate(-135deg); +} \ No newline at end of file diff --git a/internal/glance/templates/document.html b/internal/glance/templates/document.html index a26f854..e28c85e 100644 --- a/internal/glance/templates/document.html +++ b/internal/glance/templates/document.html @@ -5,7 +5,7 @@ {{ block "document-title" . }}{{ end }} - + diff --git a/internal/glance/templates/page.html b/internal/glance/templates/page.html index e740d03..5b48520 100644 --- a/internal/glance/templates/page.html +++ b/internal/glance/templates/page.html @@ -8,6 +8,11 @@ slug: "{{ .Page.Slug }}", baseURL: "{{ .App.Config.Server.BaseURL }}", }; + + /** + * @type ThemeCollection + */ + const userThemes = JSON.parse("{{ .Presets }}"); {{ end }} @@ -29,6 +34,23 @@ {{ end }} {{ end }} +{{ define "theme-switcher" }} +
+ + +
+{{ end }} + {{ define "document-body" }}
{{ if not .Page.HideDesktopNavigation }} @@ -39,6 +61,9 @@ +
+ {{ template "theme-switcher" . }} +
{{ end }} @@ -52,7 +77,12 @@ diff --git a/internal/glance/templates/theme-style.gotmpl b/internal/glance/templates/theme-style.gotmpl index 878ca0b..2492568 100644 --- a/internal/glance/templates/theme-style.gotmpl +++ b/internal/glance/templates/theme-style.gotmpl @@ -1,14 +1,31 @@ + +{{ range $name,$theme := .Presets }} +.{{ $name }} { + {{ if .BackgroundColor }} + --bgh: {{ $theme.BackgroundColor.Hue }}; + --bgs: {{ $theme.BackgroundColor.Saturation }}%; + --bgl: {{ $theme.BackgroundColor.Lightness }}%; + {{ end }} + + {{ if ne 0.0 $theme.ContrastMultiplier }}--cm: {{ $theme.ContrastMultiplier }};{{ end }} + {{ if ne 0.0 $theme.TextSaturationMultiplier }}--tsm: {{ $theme.TextSaturationMultiplier }};{{ end }} + {{ if $theme.PrimaryColor }}--color-primary: {{ $theme.PrimaryColor.String | safeCSS }};{{ end }} + {{ if $theme.PositiveColor }}--color-positive: {{ $theme.PositiveColor.String | safeCSS }};{{ end }} + {{ if $theme.NegativeColor }}--color-negative: {{ $theme.NegativeColor.String | safeCSS }};{{ end }} +} +{{ end }} + \ No newline at end of file From e84edb3e301ab37548669f6d8844a600caaba05a Mon Sep 17 00:00:00 2001 From: MikeC Date: Mon, 17 Mar 2025 06:53:08 -0400 Subject: [PATCH 002/179] Add support to configure docker containers in yaml --- internal/glance/widget-docker-containers.go | 78 +++++++++++++++++---- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/internal/glance/widget-docker-containers.go b/internal/glance/widget-docker-containers.go index f38cdeb..13f1f2a 100644 --- a/internal/glance/widget-docker-containers.go +++ b/internal/glance/widget-docker-containers.go @@ -16,9 +16,10 @@ var dockerContainersWidgetTemplate = mustParseTemplate("docker-containers.html", type dockerContainersWidget struct { widgetBase `yaml:",inline"` - HideByDefault bool `yaml:"hide-by-default"` - SockPath string `yaml:"sock-path"` - Containers dockerContainerList `yaml:"-"` + HideByDefault bool `yaml:"hide-by-default"` + SockPath string `yaml:"sock-path"` + Containers dockerContainerList `yaml:"-"` + ContainerMap map[string]dockerContainerConfig `yaml:"containers,omitempty"` } func (widget *dockerContainersWidget) initialize() error { @@ -32,7 +33,7 @@ func (widget *dockerContainersWidget) initialize() error { } func (widget *dockerContainersWidget) update(ctx context.Context) { - containers, err := fetchDockerContainers(widget.SockPath, widget.HideByDefault) + containers, err := fetchDockerContainers(widget.SockPath, widget.HideByDefault, widget.ContainerMap) if !widget.canContinueUpdateAfterHandlingErr(err) { return } @@ -110,6 +111,11 @@ type dockerContainer struct { Children dockerContainerList } +type dockerContainerConfig struct { + dockerContainer `yaml:",inline"` + Hide bool `yaml:"hide,omitempty"` +} + type dockerContainerList []dockerContainer func (containers dockerContainerList) sortByStateIconThenTitle() { @@ -137,7 +143,7 @@ func dockerContainerStateToStateIcon(state string) string { } } -func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContainerList, error) { +func fetchDockerContainers(socketPath string, hideByDefault bool, containerOverrides map[string]dockerContainerConfig) (dockerContainerList, error) { containers, err := fetchAllDockerContainersFromSock(socketPath) if err != nil { return nil, fmt.Errorf("fetching containers: %w", err) @@ -149,15 +155,40 @@ func fetchDockerContainers(socketPath string, hideByDefault bool) (dockerContain for i := range containers { container := &containers[i] + containerName := "" + if len(container.Names) > 0 { + containerName = strings.TrimLeft(container.Names[0], "/") + } + 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")), + 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 { + dc.Title = deriveDockerContainerTitle(container) + } + 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 { + dc.Title = deriveDockerContainerTitle(container) + 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 != "" { @@ -271,3 +302,24 @@ func fetchAllDockerContainersFromSock(socketPath string) ([]dockerContainerJsonR 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 +} From 075bdfdc239af004ff1450bd5c2a32ec6106fb83 Mon Sep 17 00:00:00 2001 From: MikeC Date: Mon, 17 Mar 2025 06:58:11 -0400 Subject: [PATCH 003/179] Add ability to convert docker container names to humanreadable names --- internal/glance/widget-docker-containers.go | 26 +++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/internal/glance/widget-docker-containers.go b/internal/glance/widget-docker-containers.go index 13f1f2a..4ba7725 100644 --- a/internal/glance/widget-docker-containers.go +++ b/internal/glance/widget-docker-containers.go @@ -18,6 +18,7 @@ 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"` } @@ -33,7 +34,7 @@ func (widget *dockerContainersWidget) initialize() error { } func (widget *dockerContainersWidget) update(ctx context.Context) { - containers, err := fetchDockerContainers(widget.SockPath, widget.HideByDefault, widget.ContainerMap) + containers, err := fetchDockerContainers(widget.SockPath, widget.HideByDefault, widget.ReadableNames, widget.ContainerMap) if !widget.canContinueUpdateAfterHandlingErr(err) { return } @@ -143,7 +144,16 @@ func dockerContainerStateToStateIcon(state string) string { } } -func fetchDockerContainers(socketPath string, hideByDefault bool, containerOverrides map[string]dockerContainerConfig) (dockerContainerList, error) { +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) if err != nil { return nil, fmt.Errorf("fetching containers: %w", err) @@ -175,7 +185,11 @@ func fetchDockerContainers(socketPath string, hideByDefault bool, containerOverr if override.Title != "" { dc.Title = override.Title } else { - dc.Title = deriveDockerContainerTitle(container) + title := deriveDockerContainerTitle(container) + if readableNames { + title = formatReadableName(title) + } + dc.Title = title } dc.URL = override.URL dc.Description = override.Description @@ -185,7 +199,11 @@ func fetchDockerContainers(socketPath string, hideByDefault bool, containerOverr dc.Icon = newCustomIconField(container.Labels.getOrDefault(dockerContainerLabelIcon, "si:docker")) } } else { - dc.Title = deriveDockerContainerTitle(container) + 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")) From 51e70347e49d9536375f90a9e2e489a1d0369ae5 Mon Sep 17 00:00:00 2001 From: MikeC Date: Mon, 17 Mar 2025 07:09:31 -0400 Subject: [PATCH 004/179] Add Documentation --- docs/configuration.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/configuration.md b/docs/configuration.md index ea8c76e..2ddc862 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1701,8 +1701,11 @@ 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: @@ -1727,6 +1730,21 @@ 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: + +```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" + hide: false +``` + For services with multiple containers you can specify a `glance.id` on the "main" container and `glance.parent` on each "child" container:
From f36527995ec1cd6ba2d43a1a9947a5d90e70d50d Mon Sep 17 00:00:00 2001 From: Charles Harries Date: Mon, 17 Mar 2025 19:06:32 +0000 Subject: [PATCH 005/179] feat: Use conditional requests for RSS feeds --- internal/glance/widget-rss.go | 133 ++++++++++++++++++++++++-------- internal/glance/widget-utils.go | 1 + 2 files changed, 102 insertions(+), 32 deletions(-) diff --git a/internal/glance/widget-rss.go b/internal/glance/widget-rss.go index e7d2e8b..e480e67 100644 --- a/internal/glance/widget-rss.go +++ b/internal/glance/widget-rss.go @@ -2,6 +2,7 @@ package glance import ( "context" + "errors" "fmt" "html" "html/template" @@ -25,18 +26,25 @@ var ( rssWidgetHorizontalCards2Template = mustParseTemplate("rss-horizontal-cards-2.html", "widget-base.html") ) +type cachedFeed struct { + LastModified time.Time + Etag string + Items rssFeedItemList +} + type rssWidget struct { widgetBase `yaml:",inline"` - FeedRequests []rssFeedRequest `yaml:"feeds"` - Style string `yaml:"style"` - ThumbnailHeight float64 `yaml:"thumbnail-height"` - CardHeight float64 `yaml:"card-height"` - Items rssFeedItemList `yaml:"-"` - Limit int `yaml:"limit"` - CollapseAfter int `yaml:"collapse-after"` - SingleLineTitles bool `yaml:"single-line-titles"` - PreserveOrder bool `yaml:"preserve-order"` - NoItemsMessage string `yaml:"-"` + FeedRequests []rssFeedRequest `yaml:"feeds"` + Style string `yaml:"style"` + ThumbnailHeight float64 `yaml:"thumbnail-height"` + CardHeight float64 `yaml:"card-height"` + Items rssFeedItemList `yaml:"-"` + Limit int `yaml:"limit"` + CollapseAfter int `yaml:"collapse-after"` + SingleLineTitles bool `yaml:"single-line-titles"` + PreserveOrder bool `yaml:"preserve-order"` + NoItemsMessage string `yaml:"-"` + CachedFeeds map[string]cachedFeed `yaml:"-"` } func (widget *rssWidget) initialize() error { @@ -70,21 +78,41 @@ func (widget *rssWidget) initialize() error { } func (widget *rssWidget) update(ctx context.Context) { - items, err := fetchItemsFromRSSFeeds(widget.FeedRequests) + // Populate If-Modified-Since header and Etag + for i, req := range widget.FeedRequests { + if cachedFeed, ok := widget.CachedFeeds[req.URL]; ok { + widget.FeedRequests[i].IfModifiedSince = cachedFeed.LastModified + widget.FeedRequests[i].Etag = cachedFeed.Etag + } + } + + allItems, feeds, err := fetchItemsFromRSSFeeds(widget.FeedRequests, widget.CachedFeeds) if !widget.canContinueUpdateAfterHandlingErr(err) { return } if !widget.PreserveOrder { - items.sortByNewest() + allItems.sortByNewest() } - if len(items) > widget.Limit { - items = items[:widget.Limit] + if len(allItems) > widget.Limit { + allItems = allItems[:widget.Limit] } - widget.Items = items + widget.Items = allItems + + cachedFeeds := make(map[string]cachedFeed) + for _, feed := range feeds { + if !feed.LastModified.IsZero() || feed.Etag != "" { + cachedFeeds[feed.URL] = cachedFeed{ + LastModified: feed.LastModified, + Etag: feed.Etag, + Items: feed.Items, + } + } + } + widget.CachedFeeds = cachedFeeds } func (widget *rssWidget) Render() template.HTML { @@ -152,10 +180,19 @@ type rssFeedRequest struct { ItemLinkPrefix string `yaml:"item-link-prefix"` Headers map[string]string `yaml:"headers"` IsDetailed bool `yaml:"-"` + IfModifiedSince time.Time `yaml:"-"` + Etag string `yaml:"-"` } type rssFeedItemList []rssFeedItem +type rssFeedResponse struct { + URL string + Items rssFeedItemList + LastModified time.Time + Etag string +} + func (f rssFeedItemList) sortByNewest() rssFeedItemList { sort.Slice(f, func(i, j int) bool { return f[i].PublishedAt.After(f[j].PublishedAt) @@ -166,41 +203,67 @@ func (f rssFeedItemList) sortByNewest() rssFeedItemList { var feedParser = gofeed.NewParser() -func fetchItemsFromRSSFeedTask(request rssFeedRequest) ([]rssFeedItem, error) { +func fetchItemsFromRSSFeedTask(request rssFeedRequest) (rssFeedResponse, error) { + feedResponse := rssFeedResponse{URL: request.URL} + req, err := http.NewRequest("GET", request.URL, nil) if err != nil { - return nil, err + return feedResponse, err } + req.Header.Add("User-Agent", fmt.Sprintf("Glance v%s", buildVersion)) + for key, value := range request.Headers { req.Header.Add(key, value) } + if !request.IfModifiedSince.IsZero() { + req.Header.Add("If-Modified-Since", request.IfModifiedSince.Format(http.TimeFormat)) + } + + if request.Etag != "" { + req.Header.Add("If-None-Match", request.Etag) + } + resp, err := defaultHTTPClient.Do(req) if err != nil { - return nil, err + return feedResponse, err } defer resp.Body.Close() + if resp.StatusCode == http.StatusNotModified { + return feedResponse, errNotModified + } + if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, request.URL) + return feedResponse, fmt.Errorf("unexpected status code %d from %s", resp.StatusCode, request.URL) } body, err := io.ReadAll(resp.Body) if err != nil { - return nil, err + return feedResponse, err } feed, err := feedParser.ParseString(string(body)) if err != nil { - return nil, err + return feedResponse, err } if request.Limit > 0 && len(feed.Items) > request.Limit { feed.Items = feed.Items[:request.Limit] } - items := make(rssFeedItemList, 0, len(feed.Items)) + items := make([]rssFeedItem, 0, len(feed.Items)) + + if lastModified := resp.Header.Get("Last-Modified"); lastModified != "" { + if t, err := time.Parse(http.TimeFormat, lastModified); err == nil { + feedResponse.LastModified = t + } + } + + if etag := resp.Header.Get("Etag"); etag != "" { + feedResponse.Etag = etag + } for i := range feed.Items { item := feed.Items[i] @@ -289,7 +352,8 @@ func fetchItemsFromRSSFeedTask(request rssFeedRequest) ([]rssFeedItem, error) { items = append(items, rssItem) } - return items, nil + feedResponse.Items = items + return feedResponse, nil } func recursiveFindThumbnailInExtensions(extensions map[string][]gofeedext.Extension) string { @@ -322,33 +386,38 @@ func findThumbnailInItemExtensions(item *gofeed.Item) string { return recursiveFindThumbnailInExtensions(media) } -func fetchItemsFromRSSFeeds(requests []rssFeedRequest) (rssFeedItemList, error) { +func fetchItemsFromRSSFeeds(requests []rssFeedRequest, cachedFeeds map[string]cachedFeed) (rssFeedItemList, []rssFeedResponse, error) { job := newJob(fetchItemsFromRSSFeedTask, requests).withWorkers(30) feeds, errs, err := workerPoolDo(job) if err != nil { - return nil, fmt.Errorf("%w: %v", errNoContent, err) + return nil, nil, fmt.Errorf("%w: %v", errNoContent, err) } failed := 0 + notModified := 0 + entries := make(rssFeedItemList, 0, len(feeds)*10) for i := range feeds { - if errs[i] != nil { + if errs[i] == nil { + entries = append(entries, feeds[i].Items...) + } else if errors.Is(errs[i], errNotModified) { + notModified++ + entries = append(entries, cachedFeeds[feeds[i].URL].Items...) + slog.Debug("Feed not modified", "url", requests[i].URL, "debug", errs[i]) + } else { failed++ slog.Error("Failed to get RSS feed", "url", requests[i].URL, "error", errs[i]) - continue } - - entries = append(entries, feeds[i]...) } if failed == len(requests) { - return nil, errNoContent + return nil, nil, errNoContent } if failed > 0 { - return entries, fmt.Errorf("%w: missing %d RSS feeds", errPartialContent, failed) + return entries, feeds, fmt.Errorf("%w: missing %d RSS feeds", errPartialContent, failed) } - return entries, nil + return entries, feeds, nil } diff --git a/internal/glance/widget-utils.go b/internal/glance/widget-utils.go index 8fb76dd..463258c 100644 --- a/internal/glance/widget-utils.go +++ b/internal/glance/widget-utils.go @@ -19,6 +19,7 @@ import ( var ( errNoContent = errors.New("failed to retrieve any content") errPartialContent = errors.New("failed to retrieve some of the content") + errNotModified = errors.New("content not modified") ) const defaultClientTimeout = 5 * time.Second From 0e0aca384487a4fe638b88020dd5f54ca8c6d8b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=AEdaot?= <47069617+daot@users.noreply.github.com> Date: Fri, 21 Mar 2025 18:08:15 -0500 Subject: [PATCH 006/179] Update docker-containers.html --- .../glance/templates/docker-containers.html | 66 ++++++++++--------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/internal/glance/templates/docker-containers.html b/internal/glance/templates/docker-containers.html index aeb2f0f..e266098 100644 --- a/internal/glance/templates/docker-containers.html +++ b/internal/glance/templates/docker-containers.html @@ -3,41 +3,43 @@ {{- define "widget-content" }}