diff --git a/docs/configuration.md b/docs/configuration.md index 43fb192e..31597002 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -257,6 +257,8 @@ Server configuration is done through a top level `server` property. Example: server: port: 8080 assets-path: /home/user/glance-assets + background-refresh-enabled: true + background-refresh-interval: 15m ``` ### Properties @@ -268,6 +270,8 @@ server: | proxied | boolean | no | false | | base-url | string | no | | | assets-path | string | no | | +| background-refresh-enabled | boolean | no | false | +| background-refresh-interval | duration | no | 15m | #### `host` The address which the server will listen on. Setting it to `localhost` means that only the machine that the server is running on will be able to access the dashboard. By default it will listen on all interfaces. @@ -288,6 +292,12 @@ The base URL that Glance is hosted under. No need to specify this unless you're #### `assets-path` The path to a directory that will be served by the server under the `/assets/` path. This is handy for widgets like the Monitor where you have to specify an icon URL and you want to self host all the icons rather than pointing to an external source. +#### `background-refresh-enabled` +Whether to enable automatic background refresh of widget data. When enabled, Glance will periodically update all widgets that have outdated cached data in the background, ensuring that your dashboards always show fresh information even when no one is actively viewing them. This significantly improves page load times since widget data is already current when pages are accessed. + +#### `background-refresh-interval` +How often to run the background refresh process. Accepts duration values like `5m` (5 minutes), `30s` (30 seconds), `1h` (1 hour), etc. Shorter intervals mean more up-to-date data but may increase resource usage and API calls. Longer intervals reduce resource usage but may result in slightly stale data between refresh cycles. + > [!IMPORTANT] > > When installing through docker the path will point to the files inside the container. Don't forget to mount your assets path to the same path inside the container. diff --git a/glance b/glance new file mode 100755 index 00000000..cf2ca4a8 Binary files /dev/null and b/glance differ diff --git a/internal/glance/config.go b/internal/glance/config.go index d4d6af0e..f0890c18 100644 --- a/internal/glance/config.go +++ b/internal/glance/config.go @@ -29,11 +29,13 @@ const ( type config struct { Server struct { - Host string `yaml:"host"` - Port uint16 `yaml:"port"` - Proxied bool `yaml:"proxied"` - AssetsPath string `yaml:"assets-path"` - BaseURL string `yaml:"base-url"` + Host string `yaml:"host"` + Port uint16 `yaml:"port"` + Proxied bool `yaml:"proxied"` + AssetsPath string `yaml:"assets-path"` + BaseURL string `yaml:"base-url"` + BackgroundRefreshEnabled bool `yaml:"background-refresh-enabled"` + BackgroundRefreshInterval time.Duration `yaml:"background-refresh-interval"` } `yaml:"server"` Auth struct { @@ -99,6 +101,8 @@ func newConfigFromYAML(contents []byte) (*config, error) { config := &config{} config.Server.Port = 8080 + config.Server.BackgroundRefreshEnabled = false + config.Server.BackgroundRefreshInterval = 15 * time.Minute err = yaml.Unmarshal(contents, config) if err != nil { diff --git a/internal/glance/glance.go b/internal/glance/glance.go index 28771fa5..41182e4c 100644 --- a/internal/glance/glance.go +++ b/internal/glance/glance.go @@ -42,6 +42,10 @@ type application struct { usernameHashToUsername map[string]string authAttemptsMu sync.Mutex failedAuthAttempts map[string]*failedAuthAttempt + + // Background refresh system + backgroundRefreshTicker *time.Ticker + stopBackground chan struct{} } func newApplication(c *config) (*application, error) { @@ -269,6 +273,46 @@ func (p *page) updateOutdatedWidgets() { wg.Wait() } +// updateWidgetsWithinDuration updates widgets that are outdated or will become outdated within the given duration +func (p *page) updateWidgetsWithinDuration(duration time.Duration) { + now := time.Now() + + var wg sync.WaitGroup + context := context.Background() + + for w := range p.HeadWidgets { + widget := p.HeadWidgets[w] + + if !widget.requiresUpdateWithin(&now, duration) { + continue + } + + wg.Add(1) + go func() { + defer wg.Done() + widget.update(context) + }() + } + + for c := range p.Columns { + for w := range p.Columns[c].Widgets { + widget := p.Columns[c].Widgets[w] + + if !widget.requiresUpdateWithin(&now, duration) { + continue + } + + wg.Add(1) + go func() { + defer wg.Done() + widget.update(context) + }() + } + } + + wg.Wait() +} + func (a *application) resolveUserDefinedAssetPath(path string) string { if strings.HasPrefix(path, "/assets/") { return a.Config.Server.BaseURL + path @@ -501,6 +545,11 @@ func (a *application) server() (func() error, func() error) { absAssetsPath, ) + // Start background refresh if enabled + if a.Config.Server.BackgroundRefreshEnabled { + a.startBackgroundRefresh() + } + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { return err } @@ -509,8 +558,77 @@ func (a *application) server() (func() error, func() error) { } stop := func() error { + // Stop background refresh first + a.stopBackgroundRefresh() return server.Close() } return start, stop } + +// startBackgroundRefresh starts a goroutine that periodically refreshes outdated widgets +func (a *application) startBackgroundRefresh() { + refreshInterval := a.Config.Server.BackgroundRefreshInterval + + a.backgroundRefreshTicker = time.NewTicker(refreshInterval) + a.stopBackground = make(chan struct{}) + + log.Printf("Starting background widget refresh every %v", refreshInterval) + + go func() { + for { + select { + case <-a.backgroundRefreshTicker.C: + a.refreshAllOutdatedWidgets() + case <-a.stopBackground: + return + } + } + }() +} + +// stopBackgroundRefresh stops the background refresh goroutine +func (a *application) stopBackgroundRefresh() { + if a.backgroundRefreshTicker != nil { + a.backgroundRefreshTicker.Stop() + } + if a.stopBackground != nil { + close(a.stopBackground) + } +} + +// refreshAllOutdatedWidgets proactively updates widgets that are outdated or will become outdated before the next refresh cycle +func (a *application) refreshAllOutdatedWidgets() { + log.Println("Running background widget refresh...") + + var wg sync.WaitGroup + refreshInterval := a.Config.Server.BackgroundRefreshInterval + + for _, pagePtr := range a.slugToPage { + wg.Add(1) + go func(p *page) { + defer wg.Done() + + // Use a timeout for each page to prevent hanging + done := make(chan struct{}) + go func() { + defer close(done) + p.mu.Lock() + defer p.mu.Unlock() + // Use predictive refresh: update widgets that will be outdated before next cycle + p.updateWidgetsWithinDuration(refreshInterval) + }() + + select { + case <-done: + // Update completed normally + case <-time.After(30 * time.Second): + // Timeout - log warning but continue + log.Printf("Background refresh timeout for page: %s", p.Slug) + } + }(pagePtr) + } + + wg.Wait() + log.Println("Background widget refresh completed") +} diff --git a/internal/glance/widget.go b/internal/glance/widget.go index 50dc3cb5..ca0eb627 100644 --- a/internal/glance/widget.go +++ b/internal/glance/widget.go @@ -131,6 +131,7 @@ type widget interface { initialize() error requiresUpdate(*time.Time) bool + requiresUpdateWithin(*time.Time, time.Duration) bool setProviders(*widgetProviders) update(context.Context) setID(uint64) @@ -182,6 +183,21 @@ func (w *widgetBase) requiresUpdate(now *time.Time) bool { return now.After(w.nextUpdate) } +// requiresUpdateWithin checks if the widget will require an update within the given duration +func (w *widgetBase) requiresUpdateWithin(now *time.Time, duration time.Duration) bool { + if w.cacheType == cacheTypeInfinite { + return false + } + + if w.nextUpdate.IsZero() { + return true + } + + // Check if the widget will be outdated before now + duration + futureTime := now.Add(duration) + return futureTime.After(w.nextUpdate) || now.After(w.nextUpdate) +} + func (w *widgetBase) IsWIP() bool { return w.WIP }