Skip to content

Commit

Permalink
check if config file has changed
Browse files Browse the repository at this point in the history
  • Loading branch information
damongolding committed Oct 9, 2024
1 parent 3e66cb3 commit 00d9327
Show file tree
Hide file tree
Showing 8 changed files with 206 additions and 84 deletions.
1 change: 0 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ ARG TARGETARCH
WORKDIR /app

COPY . .
COPY config.example.yaml /app/config/

RUN go mod download
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -ldflags "-X main.version=${VERSION}" -o dist/kiosk .
Expand Down
97 changes: 73 additions & 24 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@ package config
import (
"encoding/json"
"errors"
"os"
"strings"
"sync"
"time"

"github.com/charmbracelet/log"
"github.com/fsnotify/fsnotify"
"github.com/mcuadros/go-defaults"
"github.com/spf13/viper"

Expand All @@ -39,6 +39,7 @@ const (
defaultImmichPort = "2283"
defaultScheme = "http://"
DefaultDateLayout = "02/01/2006"
defaultConfigFile = "config.yaml"
)

type KioskSettings struct {
Expand All @@ -60,8 +61,14 @@ type KioskSettings struct {
}

type Config struct {
v *viper.Viper
// v is the viper instance used for configuration management
v *viper.Viper
// mu is a mutex used to ensure thread-safe access to the configuration
mu *sync.Mutex
// ReloadTimeStamp timestamp for when the last client reload was called for
ReloadTimeStamp string
// configLastModTime stores the last modification time of the configuration file
configLastModTime time.Time

// ImmichApiKey Immich key to access assets
ImmichApiKey string `mapstructure:"immich_api_key" default:""`
Expand Down Expand Up @@ -148,13 +155,29 @@ type Config struct {
// New returns a new config pointer instance
func New() *Config {
c := &Config{
v: viper.NewWithOptions(viper.ExperimentalBindStruct()),
mu: &sync.Mutex{},
v: viper.NewWithOptions(viper.ExperimentalBindStruct()),
mu: &sync.Mutex{},
ReloadTimeStamp: time.Now().Format(time.RFC3339),
}
defaults.SetDefaults(c)
info, err := os.Stat(defaultConfigFile)
if err == nil {
c.configLastModTime = info.ModTime()
}
return c
}

// hasConfigChanged checks if the configuration file has been modified since the last check.
func (c *Config) hasConfigChanged() bool {
info, err := os.Stat(defaultConfigFile)
if err != nil {
log.Errorf("Checking config file: %v", err)
return false
}

return info.ModTime().After(c.configLastModTime)
}

// bindEnvironmentVariables binds specific environment variables to their corresponding
// configuration keys in the Viper instance. This function allows for easy mapping
// between environment variables and configuration settings.
Expand Down Expand Up @@ -231,39 +254,65 @@ func (c *Config) checkDebuging() {

// Load loads yaml config file into memory, then loads ENV vars. ENV vars overwrites yaml settings.
func (c *Config) Load() error {
return c.load("config.yaml")
return c.load(defaultConfigFile)
}

// Load loads yaml config file into memory with a custom path, then loads ENV vars. ENV vars overwrites yaml settings.
func (c *Config) LoadWithConfigLocation(configPath string) error {
return c.load(configPath)
}

// WatchConfig starts a goroutine that periodically checks for changes in the configuration file
// and reloads the configuration if changes are detected.
//
// This function performs the following actions:
// 1. Retrieves the initial modification time of the config file.
// 2. Starts a goroutine that runs indefinitely.
// 3. Uses a ticker to check for config changes every 5 seconds.
// 4. If changes are detected, it reloads the configuration and updates the ReloadTimeStamp.
func (c *Config) WatchConfig() {
c.v.SetConfigFile("config.yaml")
c.v.WatchConfig()

var debounceTimer *time.Timer
debounceTimerMutex := &sync.Mutex{}
fileInfo, err := os.Stat(defaultConfigFile)
if os.IsNotExist(err) {
return
}

c.v.OnConfigChange(func(e fsnotify.Event) {
debounceTimerMutex.Lock()
defer debounceTimerMutex.Unlock()
if fileInfo.IsDir() {
log.Errorf("Config file %s is a directory", defaultConfigFile)
return
}

if debounceTimer != nil {
debounceTimer.Stop()
}
info, err := os.Stat(defaultConfigFile)
if err != nil {
log.Infof("Error getting initial file info: %v", err)
} else {
c.configLastModTime = info.ModTime()
}

debounceTimer = time.AfterFunc(200*time.Millisecond, func() {
log.Infof("%s changed, reloading config", e.Name)
c.mu.Lock()
defer c.mu.Unlock()
err := c.Load()
if err != nil {
log.Error("config watch", "err", err)
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()

//nolint:gosimple // Using for-select for ticker and potential future cases
for {
select {
case <-ticker.C:
if c.hasConfigChanged() {
log.Info("Config file changed, reloading config")
c.mu.Lock()
err := c.Load()
if err != nil {
log.Errorf("Reloading config: %v", err)
} else {
c.ReloadTimeStamp = time.Now().Format(time.RFC3339)
info, _ := os.Stat(defaultConfigFile)
c.configLastModTime = info.ModTime()
}
c.mu.Unlock()
}
}
})
})
}
}()
}

// load loads yaml config file into memory, then loads ENV vars. ENV vars overwrites yaml settings.
Expand Down
2 changes: 2 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,8 @@ func main() {

e.GET("/cache/flush", routes.FlushCache)

e.POST("/refresh/check", routes.RefreshCheck(baseConfig))

err = e.Start(":3000")
if err != nil {
log.Fatal(err)
Expand Down
7 changes: 0 additions & 7 deletions routes/routes_clock.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,11 @@ import (
func Clock(baseConfig *config.Config) echo.HandlerFunc {
return func(c echo.Context) error {

kioskVersionHeader := c.Request().Header.Get("kiosk-version")
requestID := utils.ColorizeRequestId(c.Response().Header().Get(echo.HeaderXRequestID))

// create a copy of the global config to use with this request
requestConfig := *baseConfig

// If kiosk version on client and server do not match refresh client.
if kioskVersionHeader != "" && KioskVersion != kioskVersionHeader {
c.Response().Header().Set("HX-Refresh", "true")
return c.NoContent(http.StatusOK)
}

err := requestConfig.ConfigWithOverrides(c)
if err != nil {
log.Error("overriding config", "err", err)
Expand Down
38 changes: 38 additions & 0 deletions routes/routes_refresh.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package routes

import (
"net/http"

"github.com/charmbracelet/log"
"github.com/labstack/echo/v4"

"github.com/damongolding/immich-kiosk/config"
"github.com/damongolding/immich-kiosk/utils"
)

// RefreshCheck endpoint to check if device requires a refresh
func RefreshCheck(baseConfig *config.Config) echo.HandlerFunc {
return func(c echo.Context) error {

kioskVersionHeader := c.Request().Header.Get("kiosk-version")
kioskRefreshTimestampHeader := c.Request().Header.Get("kiosk-reload-timestamp")
requestID := utils.ColorizeRequestId(c.Response().Header().Get(echo.HeaderXRequestID))

// create a copy of the global config to use with this request
requestConfig := *baseConfig

// If kiosk version on client and server do not match refresh client.
if KioskVersion != kioskVersionHeader || kioskRefreshTimestampHeader != requestConfig.ReloadTimeStamp {
c.Response().Header().Set("HX-Refresh", "true")
return c.NoContent(http.StatusOK)
}

log.Debug(
requestID,
"method", c.Request().Method,
"path", c.Request().URL.String(),
)

return c.NoContent(http.StatusOK)
}
}
4 changes: 2 additions & 2 deletions taskfile.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
version: "3"
env:
VERSION: 0.11.2
VERSION: 0.11.2-beta.2
tasks:
default:
deps: [build]
Expand Down Expand Up @@ -53,7 +53,7 @@ tasks:

docker-image:
cmds:
- docker build --build-arg VERSION={{.VERSION}} --load -t damongolding/immich-kiosk:{{.VERSION}} -t damongolding/immich-kiosk:latest .
- docker build --no-cache --build-arg VERSION={{.VERSION}} --load -t damongolding/immich-kiosk:{{.VERSION}} -t damongolding/immich-kiosk:latest .

docker-buildx:
cmds:
Expand Down
14 changes: 11 additions & 3 deletions views/views_home.templ
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ templ paramForm(queries url.Values) {
</form>
}

templ clock(queries url.Values, kioskVersion string, deviceID string, theme string) {
templ clock(queries url.Values, theme string) {
<div
id="clock"
class={ fmt.Sprintf("clock--theme-%s", theme) }
Expand All @@ -211,7 +211,6 @@ templ clock(queries url.Values, kioskVersion string, deviceID string, theme stri
}
hx-trigger="load, every 13s"
hx-swap="innerHTML"
hx-headers={ fmt.Sprintf(`{"kiosk-version": "%s", "kiosk-device-id": "%s"}`, kioskVersion, deviceID) }
></div>
}

Expand All @@ -236,6 +235,14 @@ templ sleepMode(sleepStart, sleepEnd string, queries url.Values) {
}
}

templ refreshCheckForm(kioskVersion, reloadTimeStamp string) {
<form
hx-post="/refresh/check"
hx-trigger="every 7s"
hx-headers={ fmt.Sprintf(`{"kiosk-version": "%s", "kiosk-reload-timestamp":"%s"}`, kioskVersion, reloadTimeStamp) }
></form>
}

templ Home(viewData ViewData) {
<!DOCTYPE html>
<html lang="en" class={ baseFontSize(viewData.FontSize) }>
Expand Down Expand Up @@ -289,12 +296,13 @@ templ Home(viewData ViewData) {
@progressBar()
}
if !viewData.DisableUi && (viewData.ShowTime || viewData.ShowDate) {
@clock(viewData.Queries, viewData.KioskVersion, viewData.DeviceID, viewData.Theme)
@clock(viewData.Queries, viewData.Theme)
}
@menu()
@paramForm(viewData.Queries)
@sleepMode(viewData.SleepStart, viewData.SleepEnd, viewData.Queries)
@historyForm()
@refreshCheckForm(viewData.KioskVersion, viewData.ReloadTimeStamp)
@offlineIcon()
@kioskData(map[string]any{
"debug": viewData.Kiosk.Debug,
Expand Down
Loading

0 comments on commit 00d9327

Please sign in to comment.