Просмотр исходного кода

fix: complete entity live reload on config and UI refresh

Sync entity file watchers when config reloads so new entity types in
config.yaml are watched without a restart. Skip duplicate fsnotify
goroutines for paths already watched.

Refresh the Entities page when EventEntityChanged or EventConfigChanged
fires, matching dashboards and entity details.

Part of #996

Co-authored-by: Cursor <cursoragent@cursor.com>
jamesread 1 день назад
Родитель
Сommit
6ea24149c5

+ 9 - 2
frontend/resources/vue/views/EntitiesView.vue

@@ -23,7 +23,7 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted } from 'vue'
+import { ref, computed, onMounted, onUnmounted } from 'vue'
 import { CellsIcon } from '@hugeicons/core-free-icons'
 import Section from 'picocrank/vue/components/Section.vue'
 import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
@@ -51,6 +51,13 @@ async function fetchEntities () {
 }
 
 onMounted(() => {
-	    fetchEntities()
+  fetchEntities()
+  window.addEventListener('EventEntityChanged', fetchEntities)
+  window.addEventListener('EventConfigChanged', fetchEntities)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('EventEntityChanged', fetchEntities)
+  window.removeEventListener('EventConfigChanged', fetchEntities)
 })
 </script>

+ 32 - 6
service/internal/entities/entities.go

@@ -7,6 +7,7 @@ import (
 	"os"
 	"path/filepath"
 	"strings"
+	"sync"
 
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/filehelper"
@@ -17,6 +18,9 @@ import (
 var (
 	EntityChangedSender chan bool
 	listeners           []func()
+
+	watchedMu    sync.Mutex
+	watchedPaths = map[string]struct{}{}
 )
 
 type Entity struct {
@@ -30,10 +34,16 @@ func AddListener(l func()) {
 }
 
 func SetupEntityFileWatchers(cfg *config.Config) {
+	SyncEntityFileWatchers(cfg)
+}
+
+// SyncEntityFileWatchers ensures each configured entity file has a watcher and reloads
+// entity data. Safe to call on config reload; already-watched paths are not watched twice.
+func SyncEntityFileWatchers(cfg *config.Config) {
 	baseDir := ResolveEntitiesBaseDir(cfg.GetDir())
 	for i := range cfg.Entities { // #337 - iterate by key, not by value
 		ef := cfg.Entities[i]
-		watchAndLoadEntity(baseDir, ef)
+		syncEntityFileWatcher(baseDir, ef)
 	}
 }
 
@@ -63,15 +73,31 @@ func resolveEntitiesBaseDir(configDir string) string {
 	return absConfigDir
 }
 
-func watchAndLoadEntity(baseDir string, ef *config.EntityFile) {
-	p := ef.File
+func resolveEntityFilePath(baseDir string, file string) string {
+	p := file
 	if !filepath.IsAbs(p) {
 		p = filepath.Join(baseDir, p)
 		log.WithFields(log.Fields{"entityFile": p}).Debugf("Adding config dir to entity file path")
 	}
-	go filehelper.WatchFileWrite(p, func(_ string) { loadEntityFile(p, ef.Name) }, filehelper.WatchMeta{
-		ConfigFile: ef.SourceFile,
-	})
+	return p
+}
+
+func syncEntityFileWatcher(baseDir string, ef *config.EntityFile) {
+	p := resolveEntityFilePath(baseDir, ef.File)
+
+	watchedMu.Lock()
+	_, alreadyWatched := watchedPaths[p]
+	if !alreadyWatched {
+		watchedPaths[p] = struct{}{}
+	}
+	watchedMu.Unlock()
+
+	if !alreadyWatched {
+		go filehelper.WatchFileWrite(p, func(_ string) { loadEntityFile(p, ef.Name) }, filehelper.WatchMeta{
+			ConfigFile: ef.SourceFile,
+		})
+	}
+
 	loadEntityFile(p, ef.Name)
 }
 

+ 54 - 0
service/internal/entities/entities_test.go

@@ -5,6 +5,7 @@ import (
 	"path/filepath"
 	"testing"
 
+	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 )
@@ -62,6 +63,59 @@ func TestGetEntityInstancesOrdered_emptyOrMissing(t *testing.T) {
 	assert.Nil(t, ordered)
 }
 
+func TestSyncEntityFileWatchers_doesNotDuplicateWatchers(t *testing.T) {
+	ResetEntityWatchersForTests()
+	t.Cleanup(ResetEntityWatchersForTests)
+
+	dir := t.TempDir()
+	yamlPath := filepath.Join(dir, "vehicles.yaml")
+	require.NoError(t, os.WriteFile(yamlPath, []byte("- title: car1\n"), 0o600))
+
+	cfg := config.DefaultConfig()
+	cfg.SetDir(dir)
+	cfg.Entities = []*config.EntityFile{
+		{Name: "vehicle", File: "vehicles.yaml"},
+	}
+
+	SyncEntityFileWatchers(cfg)
+	require.Equal(t, 1, watchedPathCountForTests())
+	require.Len(t, GetEntityInstancesOrdered("vehicle"), 1)
+
+	SyncEntityFileWatchers(cfg)
+	assert.Equal(t, 1, watchedPathCountForTests())
+}
+
+func TestSyncEntityFileWatchers_picksUpNewEntityType(t *testing.T) {
+	ResetEntityWatchersForTests()
+	t.Cleanup(func() {
+		ResetEntityWatchersForTests()
+		ClearEntitiesOfType("vehicle")
+		ClearEntitiesOfType("server")
+	})
+
+	dir := t.TempDir()
+	vehiclePath := filepath.Join(dir, "vehicles.yaml")
+	serverPath := filepath.Join(dir, "servers.yaml")
+	require.NoError(t, os.WriteFile(vehiclePath, []byte("- title: car1\n"), 0o600))
+
+	cfg := config.DefaultConfig()
+	cfg.SetDir(dir)
+	cfg.Entities = []*config.EntityFile{
+		{Name: "vehicle", File: "vehicles.yaml"},
+	}
+
+	SyncEntityFileWatchers(cfg)
+	require.Equal(t, 1, watchedPathCountForTests())
+
+	require.NoError(t, os.WriteFile(serverPath, []byte("- name: srv1\n"), 0o600))
+	cfg.Entities = append(cfg.Entities, &config.EntityFile{Name: "server", File: "servers.yaml"})
+
+	SyncEntityFileWatchers(cfg)
+	require.Equal(t, 2, watchedPathCountForTests())
+	require.Len(t, GetEntityInstancesOrdered("vehicle"), 1)
+	require.Len(t, GetEntityInstancesOrdered("server"), 1)
+}
+
 func TestLoadEntityFile_preservesEntitiesOnTransientFailure(t *testing.T) {
 	const entityName = "preserve_on_fail"
 	ClearEntitiesOfType(entityName)

+ 13 - 0
service/internal/entities/load_state.go

@@ -31,3 +31,16 @@ func ResetEntityLoadAttempts() {
 	defer loadAttemptedMu.Unlock()
 	loadAttempted = map[string]bool{}
 }
+
+// ResetEntityWatchersForTests clears watched-path tracking (used by tests).
+func ResetEntityWatchersForTests() {
+	watchedMu.Lock()
+	defer watchedMu.Unlock()
+	watchedPaths = map[string]struct{}{}
+}
+
+func watchedPathCountForTests() int {
+	watchedMu.Lock()
+	defer watchedMu.Unlock()
+	return len(watchedPaths)
+}

+ 4 - 1
service/main.go

@@ -262,6 +262,9 @@ func main() {
 	executor := executor.DefaultExecutor(cfg)
 	executor.RebuildActionMap()
 	config.AddListener(executor.RebuildActionMap)
+	config.AddListener(func() {
+		entities.SyncEntityFileWatchers(cfg)
+	})
 
 	executor.LoadLogsFromDisk()
 
@@ -273,7 +276,7 @@ func main() {
 	go onfileindir.WatchFilesInDirectory(cfg, executor)
 	go oncalendarfile.Schedule(cfg, executor)
 
-	go entities.SetupEntityFileWatchers(cfg)
+	go entities.SyncEntityFileWatchers(cfg)
 
 	go updatecheck.StartUpdateChecker(cfg)