Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

auto config reload on change and refresh endpoint #118

Merged
merged 3 commits into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
105 changes: 94 additions & 11 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ package config
import (
"encoding/json"
"errors"
"os"
"strings"
"sync"
"time"

"github.com/charmbracelet/log"
"github.com/mcuadros/go-defaults"
Expand All @@ -36,6 +39,7 @@ const (
defaultImmichPort = "2283"
defaultScheme = "http://"
DefaultDateLayout = "02/01/2006"
defaultConfigFile = "config.yaml"
)

type KioskSettings struct {
Expand All @@ -57,6 +61,15 @@ type KioskSettings struct {
}

type Config struct {
// 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:""`
// ImmichUrl Immuch base url
Expand Down Expand Up @@ -141,11 +154,30 @@ type Config struct {

// New returns a new config pointer instance
func New() *Config {
c := &Config{}
c := &Config{
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 @@ -222,37 +254,88 @@ 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() {

fileInfo, err := os.Stat(defaultConfigFile)
if os.IsNotExist(err) {
return
}

if fileInfo.IsDir() {
log.Errorf("Config file %s is a directory", defaultConfigFile)
return
}

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

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.
func (c *Config) load(configFile string) error {

v := viper.NewWithOptions(viper.ExperimentalBindStruct())

if err := bindEnvironmentVariables(v); err != nil {
if err := bindEnvironmentVariables(c.v); err != nil {
log.Errorf("binding environment variables: %v", err)
}

v.AddConfigPath(".")
c.v.AddConfigPath(".")

v.SetConfigFile(configFile)
c.v.SetConfigFile(configFile)

v.SetEnvPrefix("kiosk")
c.v.SetEnvPrefix("kiosk")

v.AutomaticEnv()
c.v.AutomaticEnv()

err := v.ReadInConfig()
err := c.v.ReadInConfig()
if err != nil {
log.Debug("config.yaml file not being used")
}

err = v.Unmarshal(&c)
err = c.v.Unmarshal(&c)
if err != nil {
log.Error("Environment can't be loaded", "err", err)
return err
Expand Down
2 changes: 1 addition & 1 deletion config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ func TestMalformedURLs(t *testing.T) {
t.Setenv("KIOSK_IMMICH_URL", test.KIOSK_IMMICH_URL)
t.Setenv("KIOSK_IMMICH_API_KEY", "12345")

var c Config
c := New()

err := c.Load()
assert.NoError(t, err, "Config load should not return an error")
Expand Down
4 changes: 4 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ func main() {
log.Error("Failed to load config", "err", err)
}

baseConfig.WatchConfig()

if baseConfig.Kiosk.Debug {
log.SetTimeFormat("15:04:05")

Expand Down Expand Up @@ -107,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
4 changes: 2 additions & 2 deletions routes/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import (
var (
KioskVersion string

viewDataCache *cache.Cache
ViewDataCache *cache.Cache
viewDataCacheMutex sync.Mutex
)

Expand All @@ -38,7 +38,7 @@ type RequestData struct {

func init() {
// Setting up Immich api cache
viewDataCache = cache.New(5*time.Minute, 10*time.Minute)
ViewDataCache = cache.New(5*time.Minute, 10*time.Minute)
}

func RenderError(c echo.Context, err error, message string) error {
Expand Down
6 changes: 3 additions & 3 deletions routes/routes_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,12 @@ func FlushCache(c echo.Context) error {
viewDataCacheMutex.Lock()
defer viewDataCacheMutex.Unlock()

log.Info("Cache before flush", "viewDataCache_items", viewDataCache.ItemCount(), "apiCache_items", immich.ApiCacheCount())
log.Info("Cache before flush", "viewDataCache_items", ViewDataCache.ItemCount(), "apiCache_items", immich.ApiCacheCount())

viewDataCache.Flush()
ViewDataCache.Flush()
immich.FluchApiCache()

log.Info("Cache after flush ", "viewDataCache_items", viewDataCache.ItemCount(), "apiCache_items", immich.ApiCacheCount())
log.Info("Cache after flush ", "viewDataCache_items", ViewDataCache.ItemCount(), "apiCache_items", immich.ApiCacheCount())

c.Response().Header().Set("HX-Refresh", "true")
return c.NoContent(http.StatusOK)
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
10 changes: 5 additions & 5 deletions routes/routes_image_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -252,13 +252,13 @@ func imagePreFetch(requestConfig config.Config, c echo.Context, kioskDeviceID st

cacheKey := c.Request().URL.String() + kioskDeviceID

if data, found := viewDataCache.Get(cacheKey); found {
if data, found := ViewDataCache.Get(cacheKey); found {
cachedViewData = data.([]views.ViewData)
}

cachedViewData = append(cachedViewData, viewDataToAdd)

viewDataCache.Set(cacheKey, cachedViewData, cache.DefaultExpiration)
ViewDataCache.Set(cacheKey, cachedViewData, cache.DefaultExpiration)

}

Expand Down Expand Up @@ -309,12 +309,12 @@ func fromCache(c echo.Context, kioskDeviceID string) []views.ViewData {
defer viewDataCacheMutex.Unlock()

cacheKey := c.Request().URL.String() + kioskDeviceID
if data, found := viewDataCache.Get(cacheKey); found {
if data, found := ViewDataCache.Get(cacheKey); found {
cachedPageData := data.([]views.ViewData)
if len(cachedPageData) > 0 {
return cachedPageData
}
viewDataCache.Delete(cacheKey)
ViewDataCache.Delete(cacheKey)
}
return nil
}
Expand All @@ -329,7 +329,7 @@ func renderCachedViewData(c echo.Context, cachedViewData []views.ViewData, reque
cacheKey := c.Request().URL.String() + kioskDeviceID

viewDataToRender := cachedViewData[0]
viewDataCache.Set(cacheKey, cachedViewData[1:], cache.DefaultExpiration)
ViewDataCache.Set(cacheKey, cachedViewData[1:], cache.DefaultExpiration)

// Update history which will be outdated in cache
trimHistory(&requestConfig.History, 10)
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.1
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
Loading