Ver Fonte

feat: Show config issues in diagnostics

jamesread há 2 meses atrás
pai
commit
fe1ac20935
41 ficheiros alterados com 2509 adições e 616 exclusões
  1. 19 1
      docs/modules/ROOT/pages/advanced_configuration/diagnostics.adoc
  2. 10 1
      frontend/js/websocket.js
  3. 282 237
      frontend/package-lock.json
  4. 9 9
      frontend/package.json
  5. 62 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  6. 1 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  7. 6 3
      frontend/resources/vue/App.vue
  8. 49 81
      frontend/resources/vue/views/DiagnosticsView.vue
  9. 66 0
      lang/combined_output.json
  10. 11 0
      lang/de-DE.yaml
  11. 11 0
      lang/en.yaml
  12. 11 0
      lang/es-ES.yaml
  13. 11 0
      lang/it-IT.yaml
  14. 11 0
      lang/zh-Hans-CN.yaml
  15. 11 0
      lang/zh-Hant-TW.yaml
  16. 13 0
      proto/olivetin/api/v1/olivetin.proto
  17. 268 138
      service/gen/olivetin/api/v1/olivetin.pb.go
  18. 2 0
      service/internal/api/api.go
  19. 197 0
      service/internal/api/api_config_issues_test.go
  20. 75 0
      service/internal/api/config_issues.go
  21. 4 0
      service/internal/config/config.go
  22. 30 11
      service/internal/config/config_reloader.go
  23. 18 65
      service/internal/config/sanitize.go
  24. 39 0
      service/internal/config/source_file.go
  25. 51 0
      service/internal/config/source_file_test.go
  26. 469 0
      service/internal/configcheck/rebuild.go
  27. 201 0
      service/internal/configcheck/rebuild_test.go
  28. 46 0
      service/internal/configcheck/watcher_path_test.go
  29. 33 0
      service/internal/configissues/issue.go
  30. 125 0
      service/internal/configissues/store.go
  31. 39 0
      service/internal/configissues/store_test.go
  32. 27 11
      service/internal/entities/entities.go
  33. 33 0
      service/internal/entities/load_state.go
  34. 45 0
      service/internal/executor/argument_defaults_test.go
  35. 35 17
      service/internal/executor/executor_actions.go
  36. 75 37
      service/internal/filehelper/file_change_notify.go
  37. 63 0
      service/internal/filehelper/watcher_path_test.go
  38. 5 1
      service/internal/oncalendarfile/calendar.go
  39. 10 2
      service/internal/onfileindir/fileindir.go
  40. 17 0
      service/internal/tpl/check_template_test.go
  41. 19 0
      service/internal/tpl/templates.go

+ 19 - 1
docs/modules/ROOT/pages/advanced_configuration/diagnostics.adoc

@@ -1,11 +1,29 @@
 = Diagnostics
 = Diagnostics
 
 
-OliveTin provides a built-in diagnostics page that can be used to help check how OliveTin is running and help to troubleshoot issues. It's mainly designed for checking SSH configuration at the moment.
+OliveTin provides a built-in diagnostics page that can be used to help check how OliveTin is running and help to troubleshoot issues.
 
 
 This is a screenshot of the diagnostics page, which can be accessed by clicking the "Diagnostics" link in the navigation bar:
 This is a screenshot of the diagnostics page, which can be accessed by clicking the "Diagnostics" link in the navigation bar:
 
 
 image::advanced_configuration/diagnostics/diagnostics.png[]
 image::advanced_configuration/diagnostics/diagnostics.png[]
 
 
+== Configuration issues
+
+The Diagnostics page includes a **Configuration issues** section. OliveTin checks the loaded configuration for problems such as:
+
+* Filesystem watch paths that cannot be created (for example missing directories used by `execOnFileCreatedInDir`)
+* Argument templates that fail to parse
+* Missing entity files or empty entity data for entity-bound actions
+* Invalid cron schedules
+* Entity-bound actions that also use `execOnCron` (cron cannot bind an entity)
+* Unset environment variables referenced from configuration
+* Other sanitize-time warnings (for example unknown action groups)
+
+When one or more issues exist, the Diagnostics navigation link shows a count badge with the number of issues so operators notice without opening the page first. The list and badge refresh after configuration reload and after entity file changes.
+
+Issues that refer to a specific action are only shown to users who are allowed to view that action. The badge count uses the same per-user filtering. Issues that are not tied to an action (for example unset environment variables) remain visible to anyone who can open Diagnostics.
+
+Where possible, each issue includes the configuration source file that defined the related action or entity (for example a file under an `include` directory).
+
 == Disabling Diagnostics
 == Disabling Diagnostics
 
 
 The diagnostics page is enabled by default, but you can disable it by using the OliveTin xref::security/acl.adoc#_acls_and_policies_global[security policy configuration], using the defaults, or via an ACL. Examples are shown below for each of these methods.
 The diagnostics page is enabled by default, but you can disable it by using the OliveTin xref::security/acl.adoc#_acls_and_policies_global[security policy configuration], using the defaults, or via an ACL. Examples are shown below for each of these methods.

+ 10 - 1
frontend/js/websocket.js

@@ -199,6 +199,11 @@ async function handleConfigChangedEvent (j) {
   window.dispatchEvent(j)
   window.dispatchEvent(j)
 }
 }
 
 
+async function handleEntityChangedEvent (j) {
+  await refreshInitAfterConfigChange()
+  window.dispatchEvent(j)
+}
+
 const eventCaseToTypeName = {
 const eventCaseToTypeName = {
   entityChanged: 'EventEntityChanged',
   entityChanged: 'EventEntityChanged',
   configChanged: 'EventConfigChanged',
   configChanged: 'EventConfigChanged',
@@ -230,9 +235,13 @@ function handleEvent (msg) {
     case 'EventHeartbeat':
     case 'EventHeartbeat':
       break
       break
     case 'EventOutputChunk':
     case 'EventOutputChunk':
-    case 'EventEntityChanged':
       window.dispatchEvent(j)
       window.dispatchEvent(j)
       break
       break
+    case 'EventEntityChanged':
+      handleEntityChangedEvent(j).catch((err) => {
+        console.error('EventEntityChanged handler failed:', err)
+      })
+      break
     case 'EventExecutionFinished':
     case 'EventExecutionFinished':
     case 'EventExecutionStarted':
     case 'EventExecutionStarted':
       window.dispatchEvent(j)
       window.dispatchEvent(j)

+ 282 - 237
frontend/package-lock.json

@@ -11,25 +11,25 @@
 			"dependencies": {
 			"dependencies": {
 				"@connectrpc/connect": "^2.1.2",
 				"@connectrpc/connect": "^2.1.2",
 				"@connectrpc/connect-web": "^2.1.2",
 				"@connectrpc/connect-web": "^2.1.2",
-				"@hugeicons/core-free-icons": "^4.2.2",
+				"@hugeicons/core-free-icons": "^4.2.3",
 				"@hugeicons/vue": "^1.0.7",
 				"@hugeicons/vue": "^1.0.7",
-				"@vitejs/plugin-vue": "^6.0.7",
+				"@vitejs/plugin-vue": "^6.0.8",
 				"@xterm/addon-fit": "^0.11.0",
 				"@xterm/addon-fit": "^0.11.0",
 				"@xterm/addon-web-links": "^0.12.0",
 				"@xterm/addon-web-links": "^0.12.0",
 				"@xterm/xterm": "^6.0.0",
 				"@xterm/xterm": "^6.0.0",
 				"iconify-icon": "^3.0.2",
 				"iconify-icon": "^3.0.2",
-				"picocrank": "^1.18.2",
+				"picocrank": "^1.21.0",
 				"standard": "^17.1.2",
 				"standard": "^17.1.2",
 				"unplugin-vue-components": "^32.1.0",
 				"unplugin-vue-components": "^32.1.0",
-				"vite": "^8.1.3",
-				"vue": "^3.5.39",
-				"vue-i18n": "^11.4.6",
-				"vue-router": "^5.1.0"
+				"vite": "^8.1.5",
+				"vue": "^3.5.40",
+				"vue-i18n": "^11.4.7",
+				"vue-router": "^5.2.0"
 			},
 			},
 			"devDependencies": {
 			"devDependencies": {
-				"eslint-plugin-vue": "^10.9.2",
+				"eslint-plugin-vue": "^10.10.0",
 				"process": "^0.11.10",
 				"process": "^0.11.10",
-				"stylelint": "^17.14.0",
+				"stylelint": "^17.14.1",
 				"stylelint-config-standard": "^40.0.0"
 				"stylelint-config-standard": "^40.0.0"
 			},
 			},
 			"engines": {
 			"engines": {
@@ -52,13 +52,13 @@
 			}
 			}
 		},
 		},
 		"node_modules/@babel/generator": {
 		"node_modules/@babel/generator": {
-			"version": "8.0.0-rc.5",
-			"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.5.tgz",
-			"integrity": "sha512-nFZPWz3FHIS7y6rMIVoa/WBwjdutfIaRJIBQjzn+t3RnecZoRNlGmGcyR2wb0T/IgSd50Kz/6dG8/LvMCRunjg==",
+			"version": "8.0.0",
+			"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0.tgz",
+			"integrity": "sha512-NT9NrVwJsbSV6Y2FSstWa71EETOnzrjkL5/wX3D2mYHtKM+qvqB1DvR4D0Setb/gDBsHzRICifwEWMO8CnTF6g==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@babel/parser": "^8.0.0-rc.5",
-				"@babel/types": "^8.0.0-rc.5",
+				"@babel/parser": "^8.0.0",
+				"@babel/types": "^8.0.0",
 				"@jridgewell/gen-mapping": "^0.3.12",
 				"@jridgewell/gen-mapping": "^0.3.12",
 				"@jridgewell/trace-mapping": "^0.3.28",
 				"@jridgewell/trace-mapping": "^0.3.28",
 				"@types/jsesc": "^2.5.0",
 				"@types/jsesc": "^2.5.0",
@@ -69,30 +69,30 @@
 			}
 			}
 		},
 		},
 		"node_modules/@babel/generator/node_modules/@babel/helper-string-parser": {
 		"node_modules/@babel/generator/node_modules/@babel/helper-string-parser": {
-			"version": "8.0.0-rc.5",
-			"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.5.tgz",
-			"integrity": "sha512-sN7R8rBvDurfaziNfDEIjIntlazmlkCDGO4SNl2RJ3wRCn+QxspLV7hzYAE8WWVd2joVuT8sUxeePdLp2idI1A==",
+			"version": "8.0.0",
+			"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0.tgz",
+			"integrity": "sha512-6mJgmFFFIIO82vvoLt9XtRC7/TkzXfts1t/SpRX4IHSzMgqoPYCWesVu1udUPUWioAE/2fcG6WuI8zrkE1gwrg==",
 			"license": "MIT",
 			"license": "MIT",
 			"engines": {
 			"engines": {
 				"node": "^22.18.0 || >=24.11.0"
 				"node": "^22.18.0 || >=24.11.0"
 			}
 			}
 		},
 		},
 		"node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": {
 		"node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": {
-			"version": "8.0.0-rc.5",
-			"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.5.tgz",
-			"integrity": "sha512-ehJDxHvtbZ85RtX/L2fi0h9AGsBNqB5Euv1EB8RMAvGYvD+2X+QbpzzOpbklnNXO+WSZJNOaetw2BBj27xsWVg==",
+			"version": "8.0.4",
+			"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz",
+			"integrity": "sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg==",
 			"license": "MIT",
 			"license": "MIT",
 			"engines": {
 			"engines": {
 				"node": "^22.18.0 || >=24.11.0"
 				"node": "^22.18.0 || >=24.11.0"
 			}
 			}
 		},
 		},
 		"node_modules/@babel/generator/node_modules/@babel/parser": {
 		"node_modules/@babel/generator/node_modules/@babel/parser": {
-			"version": "8.0.0-rc.5",
-			"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.5.tgz",
-			"integrity": "sha512-/Mfg83rK3+jsRbl4Vbd0jqxc6M1A1/WNFtgrowRM1unEsD3XcNnrBdMM0JWakd0/RN9lseQKwPduW1TiEwKOlQ==",
+			"version": "8.0.4",
+			"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.4.tgz",
+			"integrity": "sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@babel/types": "^8.0.0-rc.5"
+				"@babel/types": "^8.0.4"
 			},
 			},
 			"bin": {
 			"bin": {
 				"parser": "bin/babel-parser.js"
 				"parser": "bin/babel-parser.js"
@@ -102,13 +102,13 @@
 			}
 			}
 		},
 		},
 		"node_modules/@babel/generator/node_modules/@babel/types": {
 		"node_modules/@babel/generator/node_modules/@babel/types": {
-			"version": "8.0.0-rc.5",
-			"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.5.tgz",
-			"integrity": "sha512-JeSVu/m8x/zpp4CLjYHVNXuhEyOkhPXuxM8YOXjh6L4LlvQNKuUNOTo5KdBuKAcTDHw8DquToTaEkhsBqPXOaA==",
+			"version": "8.0.4",
+			"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.4.tgz",
+			"integrity": "sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@babel/helper-string-parser": "^8.0.0-rc.5",
-				"@babel/helper-validator-identifier": "^8.0.0-rc.5"
+				"@babel/helper-string-parser": "^8.0.0",
+				"@babel/helper-validator-identifier": "^8.0.4"
 			},
 			},
 			"engines": {
 			"engines": {
 				"node": "^22.18.0 || >=24.11.0"
 				"node": "^22.18.0 || >=24.11.0"
@@ -168,13 +168,13 @@
 			"peer": true
 			"peer": true
 		},
 		},
 		"node_modules/@cacheable/memory": {
 		"node_modules/@cacheable/memory": {
-			"version": "2.0.9",
-			"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.0.9.tgz",
-			"integrity": "sha512-HdMx6DoGywB30vacDbBsITbIX4pgFqj1zsrV58jZBUw3klzkNoXhj7qOqAgledhxG7YZI5rBSJg7Zp8/VG0DuA==",
+			"version": "2.2.0",
+			"resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
+			"integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==",
 			"dev": true,
 			"dev": true,
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@cacheable/utils": "^2.4.1",
+				"@cacheable/utils": "^2.5.0",
 				"@keyv/bigmap": "^1.3.1",
 				"@keyv/bigmap": "^1.3.1",
 				"hookified": "^1.15.1",
 				"hookified": "^1.15.1",
 				"keyv": "^5.6.0"
 				"keyv": "^5.6.0"
@@ -208,9 +208,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@cacheable/utils": {
 		"node_modules/@cacheable/utils": {
-			"version": "2.4.1",
-			"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.4.1.tgz",
-			"integrity": "sha512-eiFgzCbIneyMlLOmNG4g9xzF7Hv3Mga4LjxjcSC/ues6VYq2+gUbQI8JqNuw/ZM8tJIeIaBGpswAsqV2V7ApgA==",
+			"version": "2.5.0",
+			"resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz",
+			"integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==",
 			"dev": true,
 			"dev": true,
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
@@ -295,9 +295,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@csstools/css-syntax-patches-for-csstree": {
 		"node_modules/@csstools/css-syntax-patches-for-csstree": {
-			"version": "1.1.5",
-			"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz",
-			"integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==",
+			"version": "1.1.7",
+			"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
+			"integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
 			"dev": true,
 			"dev": true,
 			"funding": [
 			"funding": [
 				{
 				{
@@ -883,9 +883,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@eslint-community/eslint-utils": {
 		"node_modules/@eslint-community/eslint-utils": {
-			"version": "4.9.0",
-			"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
-			"integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
+			"version": "4.10.1",
+			"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+			"integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
 				"eslint-visitor-keys": "^3.4.3"
 				"eslint-visitor-keys": "^3.4.3"
@@ -942,9 +942,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@hugeicons/core-free-icons": {
 		"node_modules/@hugeicons/core-free-icons": {
-			"version": "4.2.2",
-			"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.2.2.tgz",
-			"integrity": "sha512-fjQW3p6cwoJmwK3oCnV8Z3PC5sHihDvr2jkAHax8cxqp62GaV/D7B/Rnao+JwicW8fmLZUQ6QrzWfoyMFOEQlQ==",
+			"version": "4.2.3",
+			"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.2.3.tgz",
+			"integrity": "sha512-fpiU0qFZkv4ABi23xJ2m4qNr0BBfuIaYjPboN8WDA6aj9D+OzXa7eykxKOpACZRuGbDz1U9S5DsNx1d5KNnsgA==",
 			"license": "MIT"
 			"license": "MIT"
 		},
 		},
 		"node_modules/@hugeicons/vue": {
 		"node_modules/@hugeicons/vue": {
@@ -998,14 +998,14 @@
 			"license": "MIT"
 			"license": "MIT"
 		},
 		},
 		"node_modules/@intlify/core-base": {
 		"node_modules/@intlify/core-base": {
-			"version": "11.4.6",
-			"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.6.tgz",
-			"integrity": "sha512-EOeHO95XESK9IFHgHeZXunsM/WBAoCA0DlaWODvx14vKmetAuS97t+l6Xe9hTUqntPpF93vtVSjjUDafw3wXMw==",
+			"version": "11.4.7",
+			"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.7.tgz",
+			"integrity": "sha512-MSB/sBKwEWJTILvQIhg2rnIcwPpLayo3wGwvVA+dJTNeUBD9GoqQgAaSOLdI9iOPDHCm9YoVnLqpfzza98MpkQ==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@intlify/devtools-types": "11.4.6",
-				"@intlify/message-compiler": "11.4.6",
-				"@intlify/shared": "11.4.6"
+				"@intlify/devtools-types": "11.4.7",
+				"@intlify/message-compiler": "11.4.7",
+				"@intlify/shared": "11.4.7"
 			},
 			},
 			"engines": {
 			"engines": {
 				"node": ">= 22"
 				"node": ">= 22"
@@ -1015,13 +1015,13 @@
 			}
 			}
 		},
 		},
 		"node_modules/@intlify/devtools-types": {
 		"node_modules/@intlify/devtools-types": {
-			"version": "11.4.6",
-			"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.6.tgz",
-			"integrity": "sha512-wowQPpNem56b2d43IJmqbrzG2FeBKe5f/kUGlpNuBmXs6OSqncF8m1+1lxHuW8ISZJF0ma2RkW3iLkw0g0G4VA==",
+			"version": "11.4.7",
+			"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.7.tgz",
+			"integrity": "sha512-GSz+J+hqH+AEpAHIYya6fSufS30OaMnG39HiZX7DmGKi3+aaLvassCfsXENEc4Wr4m68q2YP0QdMdB3D9UeAXg==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@intlify/core-base": "11.4.6",
-				"@intlify/shared": "11.4.6"
+				"@intlify/core-base": "11.4.7",
+				"@intlify/shared": "11.4.7"
 			},
 			},
 			"engines": {
 			"engines": {
 				"node": ">= 22"
 				"node": ">= 22"
@@ -1031,12 +1031,12 @@
 			}
 			}
 		},
 		},
 		"node_modules/@intlify/message-compiler": {
 		"node_modules/@intlify/message-compiler": {
-			"version": "11.4.6",
-			"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.6.tgz",
-			"integrity": "sha512-5nj3jULqeTAC1WovwMs1LQWgatTa2pM/rXN9T3XW8rdOtXW9ZF6/GLSNFTKDQmPLwclhPdgUWLJ/4w3fMeeC/Q==",
+			"version": "11.4.7",
+			"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.7.tgz",
+			"integrity": "sha512-bHxmh7n94N4N1evADeb7XTkc3jTw6Ki5biMFZVSX6Jmk+iehy8/maeH2XUsBI27rtKIK+Hzc6QnVAKggUwylKw==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@intlify/shared": "11.4.6",
+				"@intlify/shared": "11.4.7",
 				"source-map-js": "^1.0.2"
 				"source-map-js": "^1.0.2"
 			},
 			},
 			"engines": {
 			"engines": {
@@ -1047,9 +1047,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@intlify/shared": {
 		"node_modules/@intlify/shared": {
-			"version": "11.4.6",
-			"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.6.tgz",
-			"integrity": "sha512-m1p1HHAMLhqSpTRH7VnXdrN0CQ4y+9vunFkpLkbD8soIuBsnQdawZXqMCgvwI2UVF9Ww7sVaw7g9tV2VO7shoA==",
+			"version": "11.4.7",
+			"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.7.tgz",
+			"integrity": "sha512-OtjPZan3No2OZZFnMUiCVsXC6+j+XRwEywaFDk0AoayAbLuPesyDloXhJZLl9JUl5vHZeQUkYSbEA8VX+CWMjg==",
 			"license": "MIT",
 			"license": "MIT",
 			"engines": {
 			"engines": {
 				"node": ">= 22"
 				"node": ">= 22"
@@ -1481,9 +1481,9 @@
 			"license": "ISC"
 			"license": "ISC"
 		},
 		},
 		"node_modules/@vitejs/plugin-vue": {
 		"node_modules/@vitejs/plugin-vue": {
-			"version": "6.0.7",
-			"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
-			"integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==",
+			"version": "6.0.8",
+			"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz",
+			"integrity": "sha512-0ZjgOg7oO6farnNGup7yvoM/YXZV84OZxHAwtflItNa/6zzQyVb5LNxyea3FEKEX2XlagIKzrlH7wwxkKgtiew==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
 				"@rolldown/pluginutils": "^1.0.1"
 				"@rolldown/pluginutils": "^1.0.1"
@@ -1497,9 +1497,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/@vue-macros/common": {
 		"node_modules/@vue-macros/common": {
-			"version": "3.1.2",
-			"resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.2.tgz",
-			"integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==",
+			"version": "3.1.4",
+			"resolved": "https://registry.npmjs.org/@vue-macros/common/-/common-3.1.4.tgz",
+			"integrity": "sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
 				"@vue/compiler-sfc": "^3.5.22",
 				"@vue/compiler-sfc": "^3.5.22",
@@ -1524,53 +1524,53 @@
 			}
 			}
 		},
 		},
 		"node_modules/@vue/compiler-core": {
 		"node_modules/@vue/compiler-core": {
-			"version": "3.5.39",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz",
-			"integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==",
+			"version": "3.5.40",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.40.tgz",
+			"integrity": "sha512-39E8IgOhTbVDnoJFMKc2DvYnypcZwUqgUhQkccva/0m6FUwtIKSGV7n1hpVmYcFaoRAwf9pBcwnKlCEsN63ZEQ==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
 				"@babel/parser": "^7.29.7",
 				"@babel/parser": "^7.29.7",
-				"@vue/shared": "3.5.39",
+				"@vue/shared": "3.5.40",
 				"entities": "^7.0.1",
 				"entities": "^7.0.1",
 				"estree-walker": "^2.0.2",
 				"estree-walker": "^2.0.2",
 				"source-map-js": "^1.2.1"
 				"source-map-js": "^1.2.1"
 			}
 			}
 		},
 		},
 		"node_modules/@vue/compiler-dom": {
 		"node_modules/@vue/compiler-dom": {
-			"version": "3.5.39",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz",
-			"integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==",
+			"version": "3.5.40",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.40.tgz",
+			"integrity": "sha512-pwkx4vqlqOspFstrcmzwkKLePVMD3PT65imRzLhanU2V1Fj4K13g6OXjanOyzw3aTAuRk84BOmY8f3rEHqPaVA==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@vue/compiler-core": "3.5.39",
-				"@vue/shared": "3.5.39"
+				"@vue/compiler-core": "3.5.40",
+				"@vue/shared": "3.5.40"
 			}
 			}
 		},
 		},
 		"node_modules/@vue/compiler-sfc": {
 		"node_modules/@vue/compiler-sfc": {
-			"version": "3.5.39",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz",
-			"integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==",
+			"version": "3.5.40",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.40.tgz",
+			"integrity": "sha512-gIf497P4kpuALcvs5n3AEg1Vdn0pSY4XbjASIfHNYF1/MP3T2Mf2STERTubysBxCRxzJGJYtF/O7vwJrxFB3Vw==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
 				"@babel/parser": "^7.29.7",
 				"@babel/parser": "^7.29.7",
-				"@vue/compiler-core": "3.5.39",
-				"@vue/compiler-dom": "3.5.39",
-				"@vue/compiler-ssr": "3.5.39",
-				"@vue/shared": "3.5.39",
+				"@vue/compiler-core": "3.5.40",
+				"@vue/compiler-dom": "3.5.40",
+				"@vue/compiler-ssr": "3.5.40",
+				"@vue/shared": "3.5.40",
 				"estree-walker": "^2.0.2",
 				"estree-walker": "^2.0.2",
 				"magic-string": "^0.30.21",
 				"magic-string": "^0.30.21",
-				"postcss": "^8.5.15",
+				"postcss": "^8.5.19",
 				"source-map-js": "^1.2.1"
 				"source-map-js": "^1.2.1"
 			}
 			}
 		},
 		},
 		"node_modules/@vue/compiler-ssr": {
 		"node_modules/@vue/compiler-ssr": {
-			"version": "3.5.39",
-			"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz",
-			"integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==",
+			"version": "3.5.40",
+			"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.40.tgz",
+			"integrity": "sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@vue/compiler-dom": "3.5.39",
-				"@vue/shared": "3.5.39"
+				"@vue/compiler-dom": "3.5.40",
+				"@vue/shared": "3.5.40"
 			}
 			}
 		},
 		},
 		"node_modules/@vue/devtools-api": {
 		"node_modules/@vue/devtools-api": {
@@ -1580,71 +1580,69 @@
 			"license": "MIT"
 			"license": "MIT"
 		},
 		},
 		"node_modules/@vue/devtools-kit": {
 		"node_modules/@vue/devtools-kit": {
-			"version": "8.1.2",
-			"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.2.tgz",
-			"integrity": "sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==",
+			"version": "8.1.5",
+			"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.5.tgz",
+			"integrity": "sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@vue/devtools-shared": "^8.1.2",
+				"@vue/devtools-shared": "^8.1.5",
 				"birpc": "^2.6.1",
 				"birpc": "^2.6.1",
 				"hookable": "^5.5.3",
 				"hookable": "^5.5.3",
 				"perfect-debounce": "^2.0.0"
 				"perfect-debounce": "^2.0.0"
 			}
 			}
 		},
 		},
 		"node_modules/@vue/devtools-shared": {
 		"node_modules/@vue/devtools-shared": {
-			"version": "8.1.2",
-			"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.2.tgz",
-			"integrity": "sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==",
+			"version": "8.1.5",
+			"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.5.tgz",
+			"integrity": "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==",
 			"license": "MIT"
 			"license": "MIT"
 		},
 		},
 		"node_modules/@vue/reactivity": {
 		"node_modules/@vue/reactivity": {
-			"version": "3.5.39",
-			"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz",
-			"integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==",
+			"version": "3.5.40",
+			"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.40.tgz",
+			"integrity": "sha512-B7ot9UlUZOi1zbq61/LvE88ZLTV8IlajTdiZTAEiDQgrnIMIZoPr9kGw0Zw46ObW62O9+H/Be3kMbfb7kYPQZA==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@vue/shared": "3.5.39"
+				"@vue/shared": "3.5.40"
 			}
 			}
 		},
 		},
 		"node_modules/@vue/runtime-core": {
 		"node_modules/@vue/runtime-core": {
-			"version": "3.5.39",
-			"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz",
-			"integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==",
+			"version": "3.5.40",
+			"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.40.tgz",
+			"integrity": "sha512-KAZLweuZ6uUJPK1PMSQPgBU5gCjgrrfjUhSglmU9NhH+Zjepa8cnwSydPWDWHDwOgY4g3VcZ+PljbiHlURNCbw==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@vue/reactivity": "3.5.39",
-				"@vue/shared": "3.5.39"
+				"@vue/reactivity": "3.5.40",
+				"@vue/shared": "3.5.40"
 			}
 			}
 		},
 		},
 		"node_modules/@vue/runtime-dom": {
 		"node_modules/@vue/runtime-dom": {
-			"version": "3.5.39",
-			"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz",
-			"integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==",
+			"version": "3.5.40",
+			"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.40.tgz",
+			"integrity": "sha512-ZfrX8ssZQds900L9pr8AuK05ddnMsR4MPMZr8cPN9GoqoPWcXLhjvvbIA2SMv+7a97sJ1vv9pj/zxK0Cq/eEFQ==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@vue/reactivity": "3.5.39",
-				"@vue/runtime-core": "3.5.39",
-				"@vue/shared": "3.5.39",
+				"@vue/reactivity": "3.5.40",
+				"@vue/runtime-core": "3.5.40",
+				"@vue/shared": "3.5.40",
 				"csstype": "^3.2.3"
 				"csstype": "^3.2.3"
 			}
 			}
 		},
 		},
 		"node_modules/@vue/server-renderer": {
 		"node_modules/@vue/server-renderer": {
-			"version": "3.5.39",
-			"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.39.tgz",
-			"integrity": "sha512-yZSakiAGw85rZfG7UM8akMnIF+FmeiNk47uvHf2nVBBSe+dIKUhZuZq9+XgJhbV3nS5Z4ALH23/MpXofW+mbcw==",
+			"version": "3.5.40",
+			"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.40.tgz",
+			"integrity": "sha512-XNJym9WpevhTVt1HuwOrCRJ5Q+9z4BjTMrDtjTrvx74SmUll8spNTw6whWJa9mEkO4PKn5TihI/bm/8ds2QVJw==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@vue/compiler-ssr": "3.5.39",
-				"@vue/shared": "3.5.39"
-			},
-			"peerDependencies": {
-				"vue": "3.5.39"
+				"@vue/compiler-ssr": "3.5.40",
+				"@vue/runtime-dom": "3.5.40",
+				"@vue/shared": "3.5.40"
 			}
 			}
 		},
 		},
 		"node_modules/@vue/shared": {
 		"node_modules/@vue/shared": {
-			"version": "3.5.39",
-			"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
-			"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==",
+			"version": "3.5.40",
+			"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.40.tgz",
+			"integrity": "sha512-WxnBtruIqOoV3rA4jeKDWzrYI5h7Cp4+pjwDi8kWGHz+IslhiN+wguLVVhtv2l8VoU02rzDCVfDjgCl1lNpZVg==",
 			"license": "MIT"
 			"license": "MIT"
 		},
 		},
 		"node_modules/@xterm/addon-fit": {
 		"node_modules/@xterm/addon-fit": {
@@ -1974,9 +1972,9 @@
 			"license": "ISC"
 			"license": "ISC"
 		},
 		},
 		"node_modules/brace-expansion": {
 		"node_modules/brace-expansion": {
-			"version": "1.1.14",
-			"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
-			"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
+			"version": "1.1.16",
+			"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
+			"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
 				"balanced-match": "^1.0.0",
 				"balanced-match": "^1.0.0",
@@ -2018,14 +2016,14 @@
 			}
 			}
 		},
 		},
 		"node_modules/cacheable": {
 		"node_modules/cacheable": {
-			"version": "2.3.5",
-			"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.3.5.tgz",
-			"integrity": "sha512-EQfaKe09tl615iNvq/TBRWTFf1AKJNXYQSsMx0Z3EI0nA+pVsVPS8wJhnRlkbdacKPh1d0qVIhwTc2zsQNFEEg==",
+			"version": "2.5.0",
+			"resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz",
+			"integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==",
 			"dev": true,
 			"dev": true,
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@cacheable/memory": "^2.0.8",
-				"@cacheable/utils": "^2.4.1",
+				"@cacheable/memory": "^2.2.0",
+				"@cacheable/utils": "^2.5.0",
 				"hookified": "^1.15.0",
 				"hookified": "^1.15.0",
 				"keyv": "^5.6.0",
 				"keyv": "^5.6.0",
 				"qified": "^0.10.1"
 				"qified": "^0.10.1"
@@ -3015,18 +3013,18 @@
 			}
 			}
 		},
 		},
 		"node_modules/eslint-plugin-vue": {
 		"node_modules/eslint-plugin-vue": {
-			"version": "10.9.2",
-			"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.9.2.tgz",
-			"integrity": "sha512-4g7ZP3pYcuqd7Zp0pzUKcos0W+RkjBz4EGdhJ92FcYk6v03Ti/GK5NwjgsjxHK+98eXDbHeK7VtX1az7/8doZA==",
+			"version": "10.10.0",
+			"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.10.0.tgz",
+			"integrity": "sha512-dL9x9rBHqqNcByWiLOHK6L0SB97V82/NC0cZRn9cXPjM7pCuWlpQQP9bFH4vjBv80ej1ZpzAkuD8zWH1o9bZbA==",
 			"dev": true,
 			"dev": true,
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@eslint-community/eslint-utils": "^4.4.0",
+				"@eslint-community/eslint-utils": "^4.9.1",
 				"natural-compare": "^1.4.0",
 				"natural-compare": "^1.4.0",
 				"nth-check": "^2.1.1",
 				"nth-check": "^2.1.1",
-				"postcss-selector-parser": "^7.1.0",
-				"semver": "^7.6.3",
-				"xml-name-validator": "^4.0.0"
+				"postcss-selector-parser": "^7.1.4",
+				"semver": "^7.8.5",
+				"xml-name-validator": "^5.0.0"
 			},
 			},
 			"engines": {
 			"engines": {
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3232,9 +3230,9 @@
 			"license": "MIT"
 			"license": "MIT"
 		},
 		},
 		"node_modules/fast-uri": {
 		"node_modules/fast-uri": {
-			"version": "3.1.2",
-			"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
-			"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
+			"version": "3.1.4",
+			"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
+			"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
 			"dev": true,
 			"dev": true,
 			"funding": [
 			"funding": [
 				{
 				{
@@ -3597,9 +3595,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/globby": {
 		"node_modules/globby": {
-			"version": "16.2.0",
-			"resolved": "https://registry.npmjs.org/globby/-/globby-16.2.0.tgz",
-			"integrity": "sha512-QrJia2qDf5BB/V6HYlDTs0I0lBahyjLzpGQg3KT7FnCdTonAyPy2RtY802m2k4ALx6Dp752f82WsOczEVr3l6Q==",
+			"version": "16.2.2",
+			"resolved": "https://registry.npmjs.org/globby/-/globby-16.2.2.tgz",
+			"integrity": "sha512-NLvV9ubZ6NDsJaOpKPy3cQeJpKi9DcWiyCiFUpJPA0YihRqiE6RWaLUmgNNPr8MgPpLZjnBjSmou7uZBRJv9wA==",
 			"dev": true,
 			"dev": true,
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
@@ -3618,9 +3616,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/globby/node_modules/ignore": {
 		"node_modules/globby/node_modules/ignore": {
-			"version": "7.0.5",
-			"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
-			"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
+			"version": "7.0.6",
+			"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
+			"integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
 			"dev": true,
 			"dev": true,
 			"license": "MIT",
 			"license": "MIT",
 			"engines": {
 			"engines": {
@@ -4311,9 +4309,9 @@
 			"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
 			"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
 		},
 		},
 		"node_modules/js-yaml": {
 		"node_modules/js-yaml": {
-			"version": "4.2.0",
-			"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.2.0.tgz",
-			"integrity": "sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==",
+			"version": "4.3.0",
+			"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
+			"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
 			"funding": [
 			"funding": [
 				{
 				{
 					"type": "github",
 					"type": "github",
@@ -4934,9 +4932,9 @@
 			"license": "MIT"
 			"license": "MIT"
 		},
 		},
 		"node_modules/nanoid": {
 		"node_modules/nanoid": {
-			"version": "3.3.12",
-			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
-			"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
+			"version": "3.3.16",
+			"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+			"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
 			"funding": [
 			"funding": [
 				{
 				{
 					"type": "github",
 					"type": "github",
@@ -4966,6 +4964,12 @@
 				"node": ">=0.10.0"
 				"node": ">=0.10.0"
 			}
 			}
 		},
 		},
+		"node_modules/nostics": {
+			"version": "1.2.0",
+			"resolved": "https://registry.npmjs.org/nostics/-/nostics-1.2.0.tgz",
+			"integrity": "sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==",
+			"license": "MIT"
+		},
 		"node_modules/nth-check": {
 		"node_modules/nth-check": {
 			"version": "2.1.1",
 			"version": "2.1.1",
 			"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
 			"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -5268,9 +5272,9 @@
 			"license": "ISC"
 			"license": "ISC"
 		},
 		},
 		"node_modules/picocrank": {
 		"node_modules/picocrank": {
-			"version": "1.18.2",
-			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.18.2.tgz",
-			"integrity": "sha512-YR1Y00KnEPjH6OCrijSxzTHoyHLhKrsPhkA7ubOkbvcUuD7wtVKbRxfPkjAkA838dGymjoG//iFca2oLnNT5tg==",
+			"version": "1.21.0",
+			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.21.0.tgz",
+			"integrity": "sha512-KM2xyrHfgrKwAwFtyr/o4cuHYqSeTS5sNAOpSUBw/zFvTGPBUWP/K82cYXRU4Tuz7NoQD6D0VbwAs/Q+rvEjkA==",
 			"license": "ISC",
 			"license": "ISC",
 			"dependencies": {
 			"dependencies": {
 				"@hugeicons/core-free-icons": "^4.2.2",
 				"@hugeicons/core-free-icons": "^4.2.2",
@@ -5400,9 +5404,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/postcss": {
 		"node_modules/postcss": {
-			"version": "8.5.16",
-			"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
-			"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
+			"version": "8.5.22",
+			"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
+			"integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
 			"funding": [
 			"funding": [
 				{
 				{
 					"type": "opencollective",
 					"type": "opencollective",
@@ -5419,7 +5423,7 @@
 			],
 			],
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"nanoid": "^3.3.12",
+				"nanoid": "^3.3.16",
 				"picocolors": "^1.1.1",
 				"picocolors": "^1.1.1",
 				"source-map-js": "^1.2.1"
 				"source-map-js": "^1.2.1"
 			},
 			},
@@ -6233,9 +6237,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/stylelint": {
 		"node_modules/stylelint": {
-			"version": "17.14.0",
-			"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.0.tgz",
-			"integrity": "sha512-8xkHPpdqYryeIsOgfsYTmr6cIeC4nLYWk5S8BPxpodq8mIuepggkMljsHewWfuAjj/+qpRKou2QerhjMH3iasg==",
+			"version": "17.14.1",
+			"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.1.tgz",
+			"integrity": "sha512-xVQwyiuxALUBNB2fBe0tmNemg9KqLtdj3T64mioFDar79B2cU8LIyz+3KL6LdiHs9NkeNfwxpKSaIVOY8f112g==",
 			"dev": true,
 			"dev": true,
 			"funding": [
 			"funding": [
 				{
 				{
@@ -6251,7 +6255,7 @@
 			"dependencies": {
 			"dependencies": {
 				"@csstools/css-calc": "^3.2.1",
 				"@csstools/css-calc": "^3.2.1",
 				"@csstools/css-parser-algorithms": "^4.0.0",
 				"@csstools/css-parser-algorithms": "^4.0.0",
-				"@csstools/css-syntax-patches-for-csstree": "^1.1.5",
+				"@csstools/css-syntax-patches-for-csstree": "^1.1.6",
 				"@csstools/css-tokenizer": "^4.0.0",
 				"@csstools/css-tokenizer": "^4.0.0",
 				"@csstools/media-query-list-parser": "^5.0.0",
 				"@csstools/media-query-list-parser": "^5.0.0",
 				"@csstools/selector-resolve-nested": "^4.0.0",
 				"@csstools/selector-resolve-nested": "^4.0.0",
@@ -6263,9 +6267,9 @@
 				"debug": "^4.4.3",
 				"debug": "^4.4.3",
 				"fast-glob": "^3.3.3",
 				"fast-glob": "^3.3.3",
 				"fastest-levenshtein": "^1.0.16",
 				"fastest-levenshtein": "^1.0.16",
-				"file-entry-cache": "^11.1.3",
+				"file-entry-cache": "^11.1.5",
 				"global-modules": "^2.0.0",
 				"global-modules": "^2.0.0",
-				"globby": "^16.2.0",
+				"globby": "^16.2.1",
 				"globjoin": "^0.1.4",
 				"globjoin": "^0.1.4",
 				"html-tags": "^5.1.0",
 				"html-tags": "^5.1.0",
 				"ignore": "^7.0.5",
 				"ignore": "^7.0.5",
@@ -6275,12 +6279,12 @@
 				"micromatch": "^4.0.8",
 				"micromatch": "^4.0.8",
 				"normalize-path": "^3.0.0",
 				"normalize-path": "^3.0.0",
 				"picocolors": "^1.1.1",
 				"picocolors": "^1.1.1",
-				"postcss": "^8.5.15",
+				"postcss": "^8.5.16",
 				"postcss-safe-parser": "^7.0.1",
 				"postcss-safe-parser": "^7.0.1",
 				"postcss-selector-parser": "^7.1.4",
 				"postcss-selector-parser": "^7.1.4",
 				"postcss-value-parser": "^4.2.0",
 				"postcss-value-parser": "^4.2.0",
 				"string-width": "^8.2.1",
 				"string-width": "^8.2.1",
-				"supports-hyperlinks": "^4.4.0",
+				"supports-hyperlinks": "^4.5.0",
 				"svg-tags": "^1.0.0",
 				"svg-tags": "^1.0.0",
 				"table": "^6.9.0",
 				"table": "^6.9.0",
 				"write-file-atomic": "^7.0.1"
 				"write-file-atomic": "^7.0.1"
@@ -6355,23 +6359,23 @@
 			}
 			}
 		},
 		},
 		"node_modules/stylelint/node_modules/file-entry-cache": {
 		"node_modules/stylelint/node_modules/file-entry-cache": {
-			"version": "11.1.3",
-			"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.3.tgz",
-			"integrity": "sha512-oMbq0PD6VIiIwMF6LIa7MEwd/l9huKwmqRKXqmrkqIZv8CvRbfowL+L0ryAl8h//HfAS0zS+4SbYoRyAoA6BJA==",
+			"version": "11.1.5",
+			"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz",
+			"integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==",
 			"dev": true,
 			"dev": true,
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"flat-cache": "^6.1.22"
+				"flat-cache": "^6.1.23"
 			}
 			}
 		},
 		},
 		"node_modules/stylelint/node_modules/flat-cache": {
 		"node_modules/stylelint/node_modules/flat-cache": {
-			"version": "6.1.22",
-			"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.22.tgz",
-			"integrity": "sha512-N2dnzVJIphnNsjHcrxGW7DePckJ6haPrSFqpsBUhHYgwtKGVq4JrBGielEGD2fCVnsGm1zlBVZ8wGhkyuetgug==",
+			"version": "6.1.23",
+			"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz",
+			"integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==",
 			"dev": true,
 			"dev": true,
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"cacheable": "^2.3.4",
+				"cacheable": "^2.5.0",
 				"flatted": "^3.4.2",
 				"flatted": "^3.4.2",
 				"hookified": "^1.15.0"
 				"hookified": "^1.15.0"
 			}
 			}
@@ -6431,9 +6435,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/supports-hyperlinks": {
 		"node_modules/supports-hyperlinks": {
-			"version": "4.4.0",
-			"resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.4.0.tgz",
-			"integrity": "sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==",
+			"version": "4.5.0",
+			"resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-4.5.0.tgz",
+			"integrity": "sha512-ZW2OvfeCXrNTbLakPUzjQG922EeGCOteFSVoek5DKStTh898wf7zgtuFlzQN8HfZCxC3Eh02yJVrRW51hADf+w==",
 			"dev": true,
 			"dev": true,
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
@@ -6750,27 +6754,67 @@
 			}
 			}
 		},
 		},
 		"node_modules/unplugin": {
 		"node_modules/unplugin": {
-			"version": "3.0.0",
-			"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.0.0.tgz",
-			"integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==",
+			"version": "3.3.0",
+			"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-3.3.0.tgz",
+			"integrity": "sha512-qa66K+crbfyE6JK10GjvbJeRrOsuC/JpbnHctfyp/i4oBTxWOzJfRZyDiOk1PtErMFRu8JhsU/wPvOdBNWe5Rg==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
 				"@jridgewell/remapping": "^2.3.5",
 				"@jridgewell/remapping": "^2.3.5",
-				"picomatch": "^4.0.3",
+				"picomatch": "^4.0.4",
 				"webpack-virtual-modules": "^0.6.2"
 				"webpack-virtual-modules": "^0.6.2"
 			},
 			},
 			"engines": {
 			"engines": {
 				"node": "^20.19.0 || >=22.12.0"
 				"node": "^20.19.0 || >=22.12.0"
+			},
+			"peerDependencies": {
+				"@farmfe/core": "*",
+				"@rspack/core": "*",
+				"bun-types-no-globals": "*",
+				"esbuild": "*",
+				"rolldown": "*",
+				"rollup": "*",
+				"unloader": "*",
+				"vite": "*",
+				"webpack": "*"
+			},
+			"peerDependenciesMeta": {
+				"@farmfe/core": {
+					"optional": true
+				},
+				"@rspack/core": {
+					"optional": true
+				},
+				"bun-types-no-globals": {
+					"optional": true
+				},
+				"esbuild": {
+					"optional": true
+				},
+				"rolldown": {
+					"optional": true
+				},
+				"rollup": {
+					"optional": true
+				},
+				"unloader": {
+					"optional": true
+				},
+				"vite": {
+					"optional": true
+				},
+				"webpack": {
+					"optional": true
+				}
 			}
 			}
 		},
 		},
 		"node_modules/unplugin-utils": {
 		"node_modules/unplugin-utils": {
-			"version": "0.3.1",
-			"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.1.tgz",
-			"integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==",
+			"version": "0.3.2",
+			"resolved": "https://registry.npmjs.org/unplugin-utils/-/unplugin-utils-0.3.2.tgz",
+			"integrity": "sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
 				"pathe": "^2.0.3",
 				"pathe": "^2.0.3",
-				"picomatch": "^4.0.3"
+				"picomatch": "^4.0.4"
 			},
 			},
 			"engines": {
 			"engines": {
 				"node": ">=20.19.0"
 				"node": ">=20.19.0"
@@ -6872,15 +6916,15 @@
 			}
 			}
 		},
 		},
 		"node_modules/vite": {
 		"node_modules/vite": {
-			"version": "8.1.3",
-			"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
-			"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
+			"version": "8.1.5",
+			"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
+			"integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
 				"lightningcss": "^1.32.0",
 				"lightningcss": "^1.32.0",
-				"picomatch": "^4.0.4",
-				"postcss": "^8.5.16",
-				"rolldown": "~1.1.3",
+				"picomatch": "^4.0.5",
+				"postcss": "^8.5.17",
+				"rolldown": "~1.1.5",
 				"tinyglobby": "^0.2.17"
 				"tinyglobby": "^0.2.17"
 			},
 			},
 			"bin": {
 			"bin": {
@@ -6949,9 +6993,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/vite/node_modules/picomatch": {
 		"node_modules/vite/node_modules/picomatch": {
-			"version": "4.0.4",
-			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
-			"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+			"version": "4.0.5",
+			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+			"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
 			"license": "MIT",
 			"license": "MIT",
 			"engines": {
 			"engines": {
 				"node": ">=12"
 				"node": ">=12"
@@ -6961,16 +7005,16 @@
 			}
 			}
 		},
 		},
 		"node_modules/vue": {
 		"node_modules/vue": {
-			"version": "3.5.39",
-			"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz",
-			"integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==",
+			"version": "3.5.40",
+			"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.40.tgz",
+			"integrity": "sha512-+8PJ4SJXdn/cHGImF4CKdxlWHIN5Dkt7DoufRREM6h6uVCx2m7QxgcEQmmzyOK8A9mcafg7sFbJFYsdFVubTig==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@vue/compiler-dom": "3.5.39",
-				"@vue/compiler-sfc": "3.5.39",
-				"@vue/runtime-dom": "3.5.39",
-				"@vue/server-renderer": "3.5.39",
-				"@vue/shared": "3.5.39"
+				"@vue/compiler-dom": "3.5.40",
+				"@vue/compiler-sfc": "3.5.40",
+				"@vue/runtime-dom": "3.5.40",
+				"@vue/server-renderer": "3.5.40",
+				"@vue/shared": "3.5.40"
 			},
 			},
 			"peerDependencies": {
 			"peerDependencies": {
 				"typescript": "*"
 				"typescript": "*"
@@ -7074,14 +7118,14 @@
 			}
 			}
 		},
 		},
 		"node_modules/vue-i18n": {
 		"node_modules/vue-i18n": {
-			"version": "11.4.6",
-			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz",
-			"integrity": "sha512-l0gE7Rfy0phCa5ChKYkOq543Wgd39BCK6hkktfr1Ed4D99oRkgPK9ffShASZdeC8OJxGfdWmpYoAaAH6iLEuIg==",
+			"version": "11.4.7",
+			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.7.tgz",
+			"integrity": "sha512-j6RyshdPPzqLiMAUpnpvZGFPM+rRoWi14Sl5yTsquvoW0/56DWyvhAj2o9TO2YXGvb6teg8T0xrYO9jR3urvdw==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@intlify/core-base": "11.4.6",
-				"@intlify/devtools-types": "11.4.6",
-				"@intlify/shared": "11.4.6",
+				"@intlify/core-base": "11.4.7",
+				"@intlify/devtools-types": "11.4.7",
+				"@intlify/shared": "11.4.7",
 				"@vue/devtools-api": "^6.5.0"
 				"@vue/devtools-api": "^6.5.0"
 			},
 			},
 			"engines": {
 			"engines": {
@@ -7095,27 +7139,28 @@
 			}
 			}
 		},
 		},
 		"node_modules/vue-router": {
 		"node_modules/vue-router": {
-			"version": "5.1.0",
-			"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.1.0.tgz",
-			"integrity": "sha512-HAbiLzLEHQwxPgvsbOJDAwtavszEgLwri6XfyrsPECIFez8+59xc9LofWVdc/HEaSRT822lJ8H9Ns38VVond5g==",
+			"version": "5.2.0",
+			"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.2.0.tgz",
+			"integrity": "sha512-QAC5i0LEb1GLG0LXDQmHu8L7FX12j0KwU/JTKmLQUJMrn04gQdKP6Du+p0QwpHb3iy71vBlqnHQ8WAfOSAWhqw==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@babel/generator": "^8.0.0-rc.4",
-				"@vue-macros/common": "^3.1.1",
-				"@vue/devtools-api": "^8.1.2",
+				"@babel/generator": "^8.0.0",
+				"@vue-macros/common": "^3.1.3",
+				"@vue/devtools-api": "^8.1.5",
 				"ast-walker-scope": "^0.9.0",
 				"ast-walker-scope": "^0.9.0",
 				"chokidar": "^5.0.0",
 				"chokidar": "^5.0.0",
 				"json5": "^2.2.3",
 				"json5": "^2.2.3",
-				"local-pkg": "^1.1.2",
+				"local-pkg": "^1.2.1",
 				"magic-string": "^0.30.21",
 				"magic-string": "^0.30.21",
 				"mlly": "^1.8.2",
 				"mlly": "^1.8.2",
 				"muggle-string": "^0.4.1",
 				"muggle-string": "^0.4.1",
+				"nostics": "^1.1.4",
 				"pathe": "^2.0.3",
 				"pathe": "^2.0.3",
-				"picomatch": "^4.0.3",
+				"picomatch": "^4.0.5",
 				"scule": "^1.3.0",
 				"scule": "^1.3.0",
-				"tinyglobby": "^0.2.16",
-				"unplugin": "^3.0.0",
-				"unplugin-utils": "^0.3.1",
+				"tinyglobby": "^0.2.17",
+				"unplugin": "^3.3.0",
+				"unplugin-utils": "^0.3.2",
 				"yaml": "^2.9.0"
 				"yaml": "^2.9.0"
 			},
 			},
 			"funding": {
 			"funding": {
@@ -7123,10 +7168,10 @@
 			},
 			},
 			"peerDependencies": {
 			"peerDependencies": {
 				"@pinia/colada": ">=0.21.2",
 				"@pinia/colada": ">=0.21.2",
-				"@vue/compiler-sfc": "^3.5.34",
-				"pinia": "^3.0.4",
-				"vite": "^7.0.0 || ^8.0.0",
-				"vue": "^3.5.34"
+				"@vue/compiler-sfc": "^3.5.34 || ^4.0.0",
+				"pinia": "^3.0.4 || ^4.0.2",
+				"vite": "^7.3.0 || ^8.0.0",
+				"vue": "^3.5.34 || ^4.0.0"
 			},
 			},
 			"peerDependenciesMeta": {
 			"peerDependenciesMeta": {
 				"@pinia/colada": {
 				"@pinia/colada": {
@@ -7144,12 +7189,12 @@
 			}
 			}
 		},
 		},
 		"node_modules/vue-router/node_modules/@vue/devtools-api": {
 		"node_modules/vue-router/node_modules/@vue/devtools-api": {
-			"version": "8.1.2",
-			"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.2.tgz",
-			"integrity": "sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg==",
+			"version": "8.1.5",
+			"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.5.tgz",
+			"integrity": "sha512-YJipMVAKe5wT5CWf5kTYCaNV7NMNjFVxJkIkJaJ4W/nCxEBzlZzrOsYKeCymdCrFZmBS/+wTWFoUs3Jf/Q6XSQ==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@vue/devtools-kit": "^8.1.2"
+				"@vue/devtools-kit": "^8.1.5"
 			}
 			}
 		},
 		},
 		"node_modules/vue-router/node_modules/json5": {
 		"node_modules/vue-router/node_modules/json5": {
@@ -7165,9 +7210,9 @@
 			}
 			}
 		},
 		},
 		"node_modules/vue-router/node_modules/picomatch": {
 		"node_modules/vue-router/node_modules/picomatch": {
-			"version": "4.0.4",
-			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
-			"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+			"version": "4.0.5",
+			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+			"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
 			"license": "MIT",
 			"license": "MIT",
 			"engines": {
 			"engines": {
 				"node": ">=12"
 				"node": ">=12"
@@ -7320,13 +7365,13 @@
 			}
 			}
 		},
 		},
 		"node_modules/xml-name-validator": {
 		"node_modules/xml-name-validator": {
-			"version": "4.0.0",
-			"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz",
-			"integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==",
+			"version": "5.0.0",
+			"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+			"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
 			"dev": true,
 			"dev": true,
 			"license": "Apache-2.0",
 			"license": "Apache-2.0",
 			"engines": {
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 			}
 		},
 		},
 		"node_modules/yaml": {
 		"node_modules/yaml": {

+ 9 - 9
frontend/package.json

@@ -5,9 +5,9 @@
 	"repository": "https://github.com/OliveTin/OliveTin",
 	"repository": "https://github.com/OliveTin/OliveTin",
 	"source": "index.html",
 	"source": "index.html",
 	"devDependencies": {
 	"devDependencies": {
-		"eslint-plugin-vue": "^10.9.2",
+		"eslint-plugin-vue": "^10.10.0",
 		"process": "^0.11.10",
 		"process": "^0.11.10",
-		"stylelint": "^17.14.0",
+		"stylelint": "^17.14.1",
 		"stylelint-config-standard": "^40.0.0"
 		"stylelint-config-standard": "^40.0.0"
 	},
 	},
 	"scripts": {
 	"scripts": {
@@ -25,20 +25,20 @@
 	"dependencies": {
 	"dependencies": {
 		"@connectrpc/connect": "^2.1.2",
 		"@connectrpc/connect": "^2.1.2",
 		"@connectrpc/connect-web": "^2.1.2",
 		"@connectrpc/connect-web": "^2.1.2",
-		"@hugeicons/core-free-icons": "^4.2.2",
+		"@hugeicons/core-free-icons": "^4.2.3",
 		"@hugeicons/vue": "^1.0.7",
 		"@hugeicons/vue": "^1.0.7",
-		"@vitejs/plugin-vue": "^6.0.7",
+		"@vitejs/plugin-vue": "^6.0.8",
 		"@xterm/addon-fit": "^0.11.0",
 		"@xterm/addon-fit": "^0.11.0",
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/xterm": "^6.0.0",
 		"@xterm/xterm": "^6.0.0",
 		"iconify-icon": "^3.0.2",
 		"iconify-icon": "^3.0.2",
-		"picocrank": "^1.18.2",
+		"picocrank": "^1.21.0",
 		"standard": "^17.1.2",
 		"standard": "^17.1.2",
 		"unplugin-vue-components": "^32.1.0",
 		"unplugin-vue-components": "^32.1.0",
-		"vite": "^8.1.3",
-		"vue": "^3.5.39",
-		"vue-i18n": "^11.4.6",
-		"vue-router": "^5.1.0"
+		"vite": "^8.1.5",
+		"vue": "^3.5.40",
+		"vue-i18n": "^11.4.7",
+		"vue-router": "^5.2.0"
 	},
 	},
 	"engines": {
 	"engines": {
 		"node": "^20.19.0 || >=22.12.0"
 		"node": "^20.19.0 || >=22.12.0"

+ 62 - 1
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts

@@ -1,4 +1,4 @@
-// @generated by protoc-gen-es v2.12.1
+// @generated by protoc-gen-es v2.13.0
 // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
 // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
 /* eslint-disable */
 /* eslint-disable */
 
 
@@ -1666,6 +1666,57 @@ export declare type GetDiagnosticsRequest = Message<"olivetin.api.v1.GetDiagnost
  */
  */
 export declare const GetDiagnosticsRequestSchema: GenMessage<GetDiagnosticsRequest>;
 export declare const GetDiagnosticsRequestSchema: GenMessage<GetDiagnosticsRequest>;
 
 
+/**
+ * @generated from message olivetin.api.v1.ConfigIssue
+ */
+export declare type ConfigIssue = Message<"olivetin.api.v1.ConfigIssue"> & {
+  /**
+   * @generated from field: string severity = 1;
+   */
+  severity: string;
+
+  /**
+   * @generated from field: string code = 2;
+   */
+  code: string;
+
+  /**
+   * @generated from field: string message = 3;
+   */
+  message: string;
+
+  /**
+   * @generated from field: string action_id = 4;
+   */
+  actionId: string;
+
+  /**
+   * @generated from field: string action_title = 5;
+   */
+  actionTitle: string;
+
+  /**
+   * @generated from field: string argument_name = 6;
+   */
+  argumentName: string;
+
+  /**
+   * @generated from field: string source = 7;
+   */
+  source: string;
+
+  /**
+   * @generated from field: string config_file = 8;
+   */
+  configFile: string;
+};
+
+/**
+ * Describes the message olivetin.api.v1.ConfigIssue.
+ * Use `create(ConfigIssueSchema)` to create a new message.
+ */
+export declare const ConfigIssueSchema: GenMessage<ConfigIssue>;
+
 /**
 /**
  * @generated from message olivetin.api.v1.GetDiagnosticsResponse
  * @generated from message olivetin.api.v1.GetDiagnosticsResponse
  */
  */
@@ -1679,6 +1730,11 @@ export declare type GetDiagnosticsResponse = Message<"olivetin.api.v1.GetDiagnos
    * @generated from field: string SshFoundConfig = 2;
    * @generated from field: string SshFoundConfig = 2;
    */
    */
   SshFoundConfig: string;
   SshFoundConfig: string;
+
+  /**
+   * @generated from field: repeated olivetin.api.v1.ConfigIssue config_issues = 3;
+   */
+  configIssues: ConfigIssue[];
 };
 };
 
 
 /**
 /**
@@ -1829,6 +1885,11 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
    * @generated from field: bool show_navigate_on_start_icons = 25;
    * @generated from field: bool show_navigate_on_start_icons = 25;
    */
    */
   showNavigateOnStartIcons: boolean;
   showNavigateOnStartIcons: boolean;
+
+  /**
+   * @generated from field: int32 config_issue_count = 26;
+   */
+  configIssueCount: number;
 };
 };
 
 
 /**
 /**

Diff do ficheiro suprimidas por serem muito extensas
+ 1 - 1
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 6 - 3
frontend/resources/vue/App.vue

@@ -345,8 +345,8 @@ function renderNavigation () {
 
 
   const rootDashboards = window.initResponse?.rootDashboards || []
   const rootDashboards = window.initResponse?.rootDashboards || []
 
 
-  if (typeof navigation.value.clear === 'function') {
-    navigation.value.clear()
+  if (typeof navigation.value.clearNavigationLinks === 'function') {
+    navigation.value.clearNavigationLinks()
   }
   }
 
 
   for (const rootDashboard of rootDashboards) {
   for (const rootDashboard of rootDashboards) {
@@ -367,7 +367,10 @@ function renderNavigation () {
   }
   }
 
 
   if (showDiagnostics.value) {
   if (showDiagnostics.value) {
-    navigation.value.addRouterLink('Diagnostics', t('nav.diagnostics'))
+    const issueCount = window.initResponse?.configIssueCount || 0
+    navigation.value.addRouterLink('Diagnostics', t('nav.diagnostics'), {
+      count: issueCount
+    })
   }
   }
 }
 }
 
 

+ 49 - 81
frontend/resources/vue/views/DiagnosticsView.vue

@@ -1,4 +1,28 @@
 <template>
 <template>
+  <Section :title="t('diagnostics.config-issues')">
+    <p>{{ t('diagnostics.config-issues-description') }}</p>
+
+    <p v-if="!loading && configIssues.length === 0">
+      {{ t('diagnostics.config-issues-none') }}
+    </p>
+
+    <Table
+      v-else
+      :data="configIssueRows"
+      :headers="configIssueHeaders"
+      :show-pagination="false"
+    >
+      <template #cell-severity="{ value }">
+        <div
+          class="tag"
+          :class="value === 'error' ? 'fg-bad' : 'fg-warning'"
+        >
+          {{ value }}
+        </div>
+      </template>
+    </Table>
+  </Section>
+
   <Section :title="t('diagnostics.get-support')">
   <Section :title="t('diagnostics.get-support')">
     <p>
     <p>
       {{ t('diagnostics.get-support-description') }}
       {{ t('diagnostics.get-support-description') }}
@@ -84,19 +108,41 @@
 </template>
 </template>
 
 
 <script setup>
 <script setup>
-import { ref, onMounted } from 'vue'
+import { ref, computed, onMounted } from 'vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import Section from 'picocrank/vue/components/Section.vue'
+import Table from 'picocrank/vue/components/Table.vue'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
 
 
 const { t, locale } = useI18n()
 const { t, locale } = useI18n()
 
 
 const diagnostics = ref({})
 const diagnostics = ref({})
+const configIssues = ref([])
 const loading = ref(false)
 const loading = ref(false)
 const serverDiagnostics = ref('')
 const serverDiagnostics = ref('')
 const browserInfo = ref('')
 const browserInfo = ref('')
 const serverDiagnosticsCopied = ref(false)
 const serverDiagnosticsCopied = ref(false)
 const browserInfoCopied = ref(false)
 const browserInfoCopied = ref(false)
 
 
+const configIssueHeaders = computed(() => [
+  { key: 'severity', label: t('diagnostics.config-issue-severity'), sortable: true, width: '7rem' },
+  { key: 'code', label: t('diagnostics.config-issue-code'), sortable: true, width: '12rem' },
+  { key: 'message', label: t('diagnostics.config-issue-message'), sortable: false },
+  { key: 'actionTitle', label: t('diagnostics.config-issue-action'), sortable: true, width: '10rem' },
+  { key: 'argumentName', label: t('diagnostics.config-issue-argument'), sortable: true, width: '8rem' },
+  { key: 'configFile', label: t('diagnostics.config-issue-config-file'), sortable: true, width: '14rem' },
+  { key: 'source', label: t('diagnostics.config-issue-detail'), sortable: false, width: '12rem' }
+])
+
+const configIssueRows = computed(() => configIssues.value.map((issue) => ({
+  severity: issue.severity || '',
+  code: issue.code || '',
+  message: issue.message || '',
+  actionTitle: issue.actionTitle || '',
+  argumentName: issue.argumentName || '',
+  configFile: issue.configFile || '',
+  source: issue.source || ''
+})))
+
 async function fetchDiagnostics () {
 async function fetchDiagnostics () {
   loading.value = true
   loading.value = true
 
 
@@ -106,12 +152,14 @@ async function fetchDiagnostics () {
       sshFoundKey: response.SshFoundKey,
       sshFoundKey: response.SshFoundKey,
       sshFoundConfig: response.SshFoundConfig
       sshFoundConfig: response.SshFoundConfig
     }
     }
+    configIssues.value = response.configIssues || []
   } catch (err) {
   } catch (err) {
     console.error('Failed to fetch diagnostics:', err)
     console.error('Failed to fetch diagnostics:', err)
     diagnostics.value = {
     diagnostics.value = {
       sshFoundKey: t('diagnostics.unknown'),
       sshFoundKey: t('diagnostics.unknown'),
       sshFoundConfig: t('diagnostics.unknown')
       sshFoundConfig: t('diagnostics.unknown')
     }
     }
+    configIssues.value = []
   }
   }
   loading.value = false
   loading.value = false
 }
 }
@@ -265,83 +313,3 @@ onMounted(() => {
   fetchDiagnostics()
   fetchDiagnostics()
 })
 })
 </script>
 </script>
-
-<style scoped>
-.diagnostics-view {
-  padding: 1rem;
-}
-
-.diagnostics-content {
-  max-width: 800px;
-  margin: 0 auto;
-}
-
-.note {
-  background: #f8f9fa;
-  border-left: 4px solid #007bff;
-  padding: 1rem;
-  margin-bottom: 1rem;
-  border-radius: 0 4px 4px 0;
-  font-size: 0.875rem;
-  color: #495057;
-}
-
-.note a {
-  color: #007bff;
-  text-decoration: none;
-}
-
-.note a:hover {
-  text-decoration: underline;
-}
-
-.diagnostics-table {
-  width: 100%;
-  border-collapse: collapse;
-}
-
-.diagnostics-table td {
-  padding: 0.75rem 1rem;
-  border-bottom: 1px solid #f1f3f4;
-}
-
-.diagnostics-table td:first-child {
-  font-weight: 500;
-  color: #495057;
-  background: #f8f9fa;
-}
-
-.diagnostics-table tr:last-child td {
-  border-bottom: none;
-}
-
-.error-list {
-  padding: 1rem;
-}
-
-.error-item {
-  background: #f8d7da;
-  color: #721c24;
-  padding: 0.75rem;
-  margin-bottom: 0.5rem;
-  border-radius: 4px;
-  border-left: 4px solid #dc3545;
-  font-family: monospace;
-  font-size: 0.875rem;
-}
-
-.error-item:last-child {
-  margin-bottom: 0;
-}
-
-.flex-col {
-  display: flex;
-  flex-direction: column;
-}
-
-.section-content {
-  display: flex;
-  flex-direction: column;
-  gap: 1em;
-}
-</style>

+ 66 - 0
lang/combined_output.json

@@ -5,6 +5,17 @@
             "connected": "Verbunden",
             "connected": "Verbunden",
             "diagnostics.browser-info": "Browser-Informationen",
             "diagnostics.browser-info": "Browser-Informationen",
             "diagnostics.browser-info-description": "Dieser Abschnitt ermöglicht es Ihnen, einen detaillierten Bericht über Ihre Browser-Informationen zu erstellen. Dies kann bei der Fehlerbehebung von browser-spezifischen Problemen hilfreich sein.",
             "diagnostics.browser-info-description": "Dieser Abschnitt ermöglicht es Ihnen, einen detaillierten Bericht über Ihre Browser-Informationen zu erstellen. Dies kann bei der Fehlerbehebung von browser-spezifischen Problemen hilfreich sein.",
+            "diagnostics.config-issue-action": "Action",
+            "diagnostics.config-issue-argument": "Argument",
+            "diagnostics.config-issue-code": "Code",
+            "diagnostics.config-issue-config-file": "Config file",
+            "diagnostics.config-issue-detail": "Detail",
+            "diagnostics.config-issue-message": "Message",
+            "diagnostics.config-issue-severity": "Severity",
+            "diagnostics.config-issue-source": "Source",
+            "diagnostics.config-issues": "Configuration issues",
+            "diagnostics.config-issues-description": "Problems detected in the current OliveTin configuration. Fix these in your config files and reload OliveTin.",
+            "diagnostics.config-issues-none": "No configuration issues detected.",
             "diagnostics.copied": "Kopiert!",
             "diagnostics.copied": "Kopiert!",
             "diagnostics.copy-to-clipboard": "In Zwischenablage kopieren",
             "diagnostics.copy-to-clipboard": "In Zwischenablage kopieren",
             "diagnostics.found-config": "Konfiguration gefunden",
             "diagnostics.found-config": "Konfiguration gefunden",
@@ -84,6 +95,17 @@
             "connected": "Connected",
             "connected": "Connected",
             "diagnostics.browser-info": "Browser Info",
             "diagnostics.browser-info": "Browser Info",
             "diagnostics.browser-info-description": "This section allows you to generate a detailed report of your browser information. This can be helpful when troubleshooting browser-specific issues.",
             "diagnostics.browser-info-description": "This section allows you to generate a detailed report of your browser information. This can be helpful when troubleshooting browser-specific issues.",
+            "diagnostics.config-issue-action": "Action",
+            "diagnostics.config-issue-argument": "Argument",
+            "diagnostics.config-issue-code": "Code",
+            "diagnostics.config-issue-config-file": "Config file",
+            "diagnostics.config-issue-detail": "Detail",
+            "diagnostics.config-issue-message": "Message",
+            "diagnostics.config-issue-severity": "Severity",
+            "diagnostics.config-issue-source": "Source",
+            "diagnostics.config-issues": "Configuration issues",
+            "diagnostics.config-issues-description": "Problems detected in the current OliveTin configuration. Fix these in your config files and reload OliveTin.",
+            "diagnostics.config-issues-none": "No configuration issues detected.",
             "diagnostics.copied": "Copied!",
             "diagnostics.copied": "Copied!",
             "diagnostics.copy-to-clipboard": "Copy to Clipboard",
             "diagnostics.copy-to-clipboard": "Copy to Clipboard",
             "diagnostics.found-config": "Found Config",
             "diagnostics.found-config": "Found Config",
@@ -163,6 +185,17 @@
             "connected": "Conectado",
             "connected": "Conectado",
             "diagnostics.browser-info": "Información del navegador",
             "diagnostics.browser-info": "Información del navegador",
             "diagnostics.browser-info-description": "Esta sección le permite generar un informe detallado de su información del navegador. Esto puede ser útil al solucionar problemas específicos del navegador.",
             "diagnostics.browser-info-description": "Esta sección le permite generar un informe detallado de su información del navegador. Esto puede ser útil al solucionar problemas específicos del navegador.",
+            "diagnostics.config-issue-action": "Action",
+            "diagnostics.config-issue-argument": "Argument",
+            "diagnostics.config-issue-code": "Code",
+            "diagnostics.config-issue-config-file": "Config file",
+            "diagnostics.config-issue-detail": "Detail",
+            "diagnostics.config-issue-message": "Message",
+            "diagnostics.config-issue-severity": "Severity",
+            "diagnostics.config-issue-source": "Source",
+            "diagnostics.config-issues": "Configuration issues",
+            "diagnostics.config-issues-description": "Problems detected in the current OliveTin configuration. Fix these in your config files and reload OliveTin.",
+            "diagnostics.config-issues-none": "No configuration issues detected.",
             "diagnostics.copied": "¡Copiado!",
             "diagnostics.copied": "¡Copiado!",
             "diagnostics.copy-to-clipboard": "Copiar al portapapeles",
             "diagnostics.copy-to-clipboard": "Copiar al portapapeles",
             "diagnostics.found-config": "Configuración encontrada",
             "diagnostics.found-config": "Configuración encontrada",
@@ -242,6 +275,17 @@
             "connected": "Connesso",
             "connected": "Connesso",
             "diagnostics.browser-info": "Informazioni del browser",
             "diagnostics.browser-info": "Informazioni del browser",
             "diagnostics.browser-info-description": "Questa sezione ti consente di generare un rapporto dettagliato delle informazioni del tuo browser. Questo può essere utile durante la risoluzione dei problemi specifici del browser.",
             "diagnostics.browser-info-description": "Questa sezione ti consente di generare un rapporto dettagliato delle informazioni del tuo browser. Questo può essere utile durante la risoluzione dei problemi specifici del browser.",
+            "diagnostics.config-issue-action": "Action",
+            "diagnostics.config-issue-argument": "Argument",
+            "diagnostics.config-issue-code": "Code",
+            "diagnostics.config-issue-config-file": "Config file",
+            "diagnostics.config-issue-detail": "Detail",
+            "diagnostics.config-issue-message": "Message",
+            "diagnostics.config-issue-severity": "Severity",
+            "diagnostics.config-issue-source": "Source",
+            "diagnostics.config-issues": "Configuration issues",
+            "diagnostics.config-issues-description": "Problems detected in the current OliveTin configuration. Fix these in your config files and reload OliveTin.",
+            "diagnostics.config-issues-none": "No configuration issues detected.",
             "diagnostics.copied": "Copiato!",
             "diagnostics.copied": "Copiato!",
             "diagnostics.copy-to-clipboard": "Copia negli appunti",
             "diagnostics.copy-to-clipboard": "Copia negli appunti",
             "diagnostics.found-config": "Configurazione trovata",
             "diagnostics.found-config": "Configurazione trovata",
@@ -321,6 +365,17 @@
             "connected": "已连接",
             "connected": "已连接",
             "diagnostics.browser-info": "浏览器信息",
             "diagnostics.browser-info": "浏览器信息",
             "diagnostics.browser-info-description": "此部分允许您生成浏览器信息的详细报告。这在排查浏览器特定问题时很有帮助。",
             "diagnostics.browser-info-description": "此部分允许您生成浏览器信息的详细报告。这在排查浏览器特定问题时很有帮助。",
+            "diagnostics.config-issue-action": "Action",
+            "diagnostics.config-issue-argument": "Argument",
+            "diagnostics.config-issue-code": "Code",
+            "diagnostics.config-issue-config-file": "Config file",
+            "diagnostics.config-issue-detail": "Detail",
+            "diagnostics.config-issue-message": "Message",
+            "diagnostics.config-issue-severity": "Severity",
+            "diagnostics.config-issue-source": "Source",
+            "diagnostics.config-issues": "Configuration issues",
+            "diagnostics.config-issues-description": "Problems detected in the current OliveTin configuration. Fix these in your config files and reload OliveTin.",
+            "diagnostics.config-issues-none": "No configuration issues detected.",
             "diagnostics.copied": "已复制!",
             "diagnostics.copied": "已复制!",
             "diagnostics.copy-to-clipboard": "复制到剪贴板",
             "diagnostics.copy-to-clipboard": "复制到剪贴板",
             "diagnostics.found-config": "找到配置",
             "diagnostics.found-config": "找到配置",
@@ -400,6 +455,17 @@
             "connected": "已連線",
             "connected": "已連線",
             "diagnostics.browser-info": "瀏覽器資訊",
             "diagnostics.browser-info": "瀏覽器資訊",
             "diagnostics.browser-info-description": "此區塊可讓您產生瀏覽器資訊的詳細報告。這在排解特定瀏覽器問題時相當有幫助。",
             "diagnostics.browser-info-description": "此區塊可讓您產生瀏覽器資訊的詳細報告。這在排解特定瀏覽器問題時相當有幫助。",
+            "diagnostics.config-issue-action": "Action",
+            "diagnostics.config-issue-argument": "Argument",
+            "diagnostics.config-issue-code": "Code",
+            "diagnostics.config-issue-config-file": "Config file",
+            "diagnostics.config-issue-detail": "Detail",
+            "diagnostics.config-issue-message": "Message",
+            "diagnostics.config-issue-severity": "Severity",
+            "diagnostics.config-issue-source": "Source",
+            "diagnostics.config-issues": "Configuration issues",
+            "diagnostics.config-issues-description": "Problems detected in the current OliveTin configuration. Fix these in your config files and reload OliveTin.",
+            "diagnostics.config-issues-none": "No configuration issues detected.",
             "diagnostics.copied": "已複製!",
             "diagnostics.copied": "已複製!",
             "diagnostics.copy-to-clipboard": "複製到剪貼簿",
             "diagnostics.copy-to-clipboard": "複製到剪貼簿",
             "diagnostics.found-config": "找到設定檔 (Config)",
             "diagnostics.found-config": "找到設定檔 (Config)",

+ 11 - 0
lang/de-DE.yaml

@@ -54,6 +54,17 @@ translations:
   diagnostics.get-support: Unterstützung erhalten Unterstützung erhalten
   diagnostics.get-support: Unterstützung erhalten Unterstützung erhalten
   diagnostics.get-support-description: Wenn Sie Probleme mit OliveTin haben und eine Support-Anfrage stellen möchten, wäre es sehr hilfreich, Server-Diagnostik von dieser Seite einzufügen.
   diagnostics.get-support-description: Wenn Sie Probleme mit OliveTin haben und eine Support-Anfrage stellen möchten, wäre es sehr hilfreich, Server-Diagnostik von dieser Seite einzufügen.
   diagnostics.where-to-find-help: Wo Sie Hilfe finden
   diagnostics.where-to-find-help: Wo Sie Hilfe finden
+  diagnostics.config-issues: Configuration issues
+  diagnostics.config-issues-description: Problems detected in the current OliveTin configuration. Fix these in your config files and reload OliveTin.
+  diagnostics.config-issues-none: No configuration issues detected.
+  diagnostics.config-issue-severity: Severity
+  diagnostics.config-issue-code: Code
+  diagnostics.config-issue-message: Message
+  diagnostics.config-issue-action: Action
+  diagnostics.config-issue-argument: Argument
+  diagnostics.config-issue-config-file: Config file
+  diagnostics.config-issue-detail: Detail
+  diagnostics.config-issue-source: Source
   diagnostics.ssh: SSH
   diagnostics.ssh: SSH
   diagnostics.found-key: Schlüssel gefunden
   diagnostics.found-key: Schlüssel gefunden
   diagnostics.found-config: Konfiguration gefunden
   diagnostics.found-config: Konfiguration gefunden

+ 11 - 0
lang/en.yaml

@@ -54,6 +54,17 @@ translations:
   diagnostics.get-support: Get support
   diagnostics.get-support: Get support
   diagnostics.get-support-description: If you are having problems with OliveTin and want to raise a support request, it would be very helpful to include Server Diagnostics from this page.
   diagnostics.get-support-description: If you are having problems with OliveTin and want to raise a support request, it would be very helpful to include Server Diagnostics from this page.
   diagnostics.where-to-find-help: Where to find help
   diagnostics.where-to-find-help: Where to find help
+  diagnostics.config-issues: Configuration issues
+  diagnostics.config-issues-description: Problems detected in the current OliveTin configuration. Fix these in your config files and reload OliveTin.
+  diagnostics.config-issues-none: No configuration issues detected.
+  diagnostics.config-issue-severity: Severity
+  diagnostics.config-issue-code: Code
+  diagnostics.config-issue-message: Message
+  diagnostics.config-issue-action: Action
+  diagnostics.config-issue-argument: Argument
+  diagnostics.config-issue-config-file: Config file
+  diagnostics.config-issue-detail: Detail
+  diagnostics.config-issue-source: Source
   diagnostics.ssh: SSH
   diagnostics.ssh: SSH
   diagnostics.found-key: Found Key
   diagnostics.found-key: Found Key
   diagnostics.found-config: Found Config
   diagnostics.found-config: Found Config

+ 11 - 0
lang/es-ES.yaml

@@ -54,6 +54,17 @@ translations:
   diagnostics.get-support: Obtener soporte Obtener soporte
   diagnostics.get-support: Obtener soporte Obtener soporte
   diagnostics.get-support-description: Si tiene problemas con OliveTin y desea presentar una solicitud de soporte, sería muy útil incluir diagnósticos del servidor desde esta página.
   diagnostics.get-support-description: Si tiene problemas con OliveTin y desea presentar una solicitud de soporte, sería muy útil incluir diagnósticos del servidor desde esta página.
   diagnostics.where-to-find-help: Dónde encontrar ayuda
   diagnostics.where-to-find-help: Dónde encontrar ayuda
+  diagnostics.config-issues: Configuration issues
+  diagnostics.config-issues-description: Problems detected in the current OliveTin configuration. Fix these in your config files and reload OliveTin.
+  diagnostics.config-issues-none: No configuration issues detected.
+  diagnostics.config-issue-severity: Severity
+  diagnostics.config-issue-code: Code
+  diagnostics.config-issue-message: Message
+  diagnostics.config-issue-action: Action
+  diagnostics.config-issue-argument: Argument
+  diagnostics.config-issue-config-file: Config file
+  diagnostics.config-issue-detail: Detail
+  diagnostics.config-issue-source: Source
   diagnostics.ssh: SSH
   diagnostics.ssh: SSH
   diagnostics.found-key: Clave encontrada
   diagnostics.found-key: Clave encontrada
   diagnostics.found-config: Configuración encontrada
   diagnostics.found-config: Configuración encontrada

+ 11 - 0
lang/it-IT.yaml

@@ -54,6 +54,17 @@ translations:
   diagnostics.get-support: Ottenere supporto Ottenere supporto
   diagnostics.get-support: Ottenere supporto Ottenere supporto
   diagnostics.get-support-description: Se hai problemi con OliveTin e vuoi presentare una richiesta di supporto, sarebbe molto utile includere la diagnostica del server da questa pagina.
   diagnostics.get-support-description: Se hai problemi con OliveTin e vuoi presentare una richiesta di supporto, sarebbe molto utile includere la diagnostica del server da questa pagina.
   diagnostics.where-to-find-help: Dove trovare aiuto
   diagnostics.where-to-find-help: Dove trovare aiuto
+  diagnostics.config-issues: Configuration issues
+  diagnostics.config-issues-description: Problems detected in the current OliveTin configuration. Fix these in your config files and reload OliveTin.
+  diagnostics.config-issues-none: No configuration issues detected.
+  diagnostics.config-issue-severity: Severity
+  diagnostics.config-issue-code: Code
+  diagnostics.config-issue-message: Message
+  diagnostics.config-issue-action: Action
+  diagnostics.config-issue-argument: Argument
+  diagnostics.config-issue-config-file: Config file
+  diagnostics.config-issue-detail: Detail
+  diagnostics.config-issue-source: Source
   diagnostics.ssh: SSH
   diagnostics.ssh: SSH
   diagnostics.found-key: Chiave trovata
   diagnostics.found-key: Chiave trovata
   diagnostics.found-config: Configurazione trovata
   diagnostics.found-config: Configurazione trovata

+ 11 - 0
lang/zh-Hans-CN.yaml

@@ -63,6 +63,17 @@ translations:
   diagnostics.get-support: 获取支持 获取支持
   diagnostics.get-support: 获取支持 获取支持
   diagnostics.get-support-description: 如果您在使用 OliveTin 时遇到问题并希望提交支持请求,从本页面包含服务器诊断将非常有帮助。
   diagnostics.get-support-description: 如果您在使用 OliveTin 时遇到问题并希望提交支持请求,从本页面包含服务器诊断将非常有帮助。
   diagnostics.where-to-find-help: 在哪里找到帮助
   diagnostics.where-to-find-help: 在哪里找到帮助
+  diagnostics.config-issues: Configuration issues
+  diagnostics.config-issues-description: Problems detected in the current OliveTin configuration. Fix these in your config files and reload OliveTin.
+  diagnostics.config-issues-none: No configuration issues detected.
+  diagnostics.config-issue-severity: Severity
+  diagnostics.config-issue-code: Code
+  diagnostics.config-issue-message: Message
+  diagnostics.config-issue-action: Action
+  diagnostics.config-issue-argument: Argument
+  diagnostics.config-issue-config-file: Config file
+  diagnostics.config-issue-detail: Detail
+  diagnostics.config-issue-source: Source
   diagnostics.ssh: SSH
   diagnostics.ssh: SSH
   diagnostics.found-key: 找到密钥
   diagnostics.found-key: 找到密钥
   diagnostics.found-config: 找到配置
   diagnostics.found-config: 找到配置

+ 11 - 0
lang/zh-Hant-TW.yaml

@@ -54,6 +54,17 @@ translations:
   diagnostics.get-support: 尋求技術支援
   diagnostics.get-support: 尋求技術支援
   diagnostics.get-support-description: 如果您在使用 OliveTin 時遇到問題並希望提出支援請求,提供此頁面的「伺服器診斷資訊」將會有很大的幫助。
   diagnostics.get-support-description: 如果您在使用 OliveTin 時遇到問題並希望提出支援請求,提供此頁面的「伺服器診斷資訊」將會有很大的幫助。
   diagnostics.where-to-find-help: 哪裡可以尋求協助
   diagnostics.where-to-find-help: 哪裡可以尋求協助
+  diagnostics.config-issues: Configuration issues
+  diagnostics.config-issues-description: Problems detected in the current OliveTin configuration. Fix these in your config files and reload OliveTin.
+  diagnostics.config-issues-none: No configuration issues detected.
+  diagnostics.config-issue-severity: Severity
+  diagnostics.config-issue-code: Code
+  diagnostics.config-issue-message: Message
+  diagnostics.config-issue-action: Action
+  diagnostics.config-issue-argument: Argument
+  diagnostics.config-issue-config-file: Config file
+  diagnostics.config-issue-detail: Detail
+  diagnostics.config-issue-source: Source
   diagnostics.ssh: SSH
   diagnostics.ssh: SSH
   diagnostics.found-key: 找到金鑰 (Key)
   diagnostics.found-key: 找到金鑰 (Key)
   diagnostics.found-config: 找到設定檔 (Config)
   diagnostics.found-config: 找到設定檔 (Config)

+ 13 - 0
proto/olivetin/api/v1/olivetin.proto

@@ -379,9 +379,21 @@ message LogoutResponse {
 message GetDiagnosticsRequest {
 message GetDiagnosticsRequest {
 }
 }
 
 
+message ConfigIssue {
+	string severity = 1;
+	string code = 2;
+	string message = 3;
+	string action_id = 4;
+	string action_title = 5;
+	string argument_name = 6;
+	string source = 7;
+	string config_file = 8;
+}
+
 message GetDiagnosticsResponse {
 message GetDiagnosticsResponse {
 	string SshFoundKey = 1;
 	string SshFoundKey = 1;
 	string SshFoundConfig = 2;
 	string SshFoundConfig = 2;
+	repeated ConfigIssue config_issues = 3;
 }
 }
 
 
 message InitRequest {}
 message InitRequest {}
@@ -412,6 +424,7 @@ message InitResponse {
 	bool login_required = 23;
 	bool login_required = 23;
 	repeated string available_themes = 24; // List of available theme names
 	repeated string available_themes = 24; // List of available theme names
 	bool show_navigate_on_start_icons = 25;
 	bool show_navigate_on_start_icons = 25;
+	int32 config_issue_count = 26;
 }
 }
 
 
 message AdditionalLink {
 message AdditionalLink {

+ 268 - 138
service/gen/olivetin/api/v1/olivetin.pb.go

@@ -3871,17 +3871,118 @@ func (*GetDiagnosticsRequest) Descriptor() ([]byte, []int) {
 	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{64}
 	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{64}
 }
 }
 
 
+type ConfigIssue struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Severity      string                 `protobuf:"bytes,1,opt,name=severity,proto3" json:"severity,omitempty"`
+	Code          string                 `protobuf:"bytes,2,opt,name=code,proto3" json:"code,omitempty"`
+	Message       string                 `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"`
+	ActionId      string                 `protobuf:"bytes,4,opt,name=action_id,json=actionId,proto3" json:"action_id,omitempty"`
+	ActionTitle   string                 `protobuf:"bytes,5,opt,name=action_title,json=actionTitle,proto3" json:"action_title,omitempty"`
+	ArgumentName  string                 `protobuf:"bytes,6,opt,name=argument_name,json=argumentName,proto3" json:"argument_name,omitempty"`
+	Source        string                 `protobuf:"bytes,7,opt,name=source,proto3" json:"source,omitempty"`
+	ConfigFile    string                 `protobuf:"bytes,8,opt,name=config_file,json=configFile,proto3" json:"config_file,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *ConfigIssue) Reset() {
+	*x = ConfigIssue{}
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[65]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *ConfigIssue) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ConfigIssue) ProtoMessage() {}
+
+func (x *ConfigIssue) ProtoReflect() protoreflect.Message {
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[65]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use ConfigIssue.ProtoReflect.Descriptor instead.
+func (*ConfigIssue) Descriptor() ([]byte, []int) {
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{65}
+}
+
+func (x *ConfigIssue) GetSeverity() string {
+	if x != nil {
+		return x.Severity
+	}
+	return ""
+}
+
+func (x *ConfigIssue) GetCode() string {
+	if x != nil {
+		return x.Code
+	}
+	return ""
+}
+
+func (x *ConfigIssue) GetMessage() string {
+	if x != nil {
+		return x.Message
+	}
+	return ""
+}
+
+func (x *ConfigIssue) GetActionId() string {
+	if x != nil {
+		return x.ActionId
+	}
+	return ""
+}
+
+func (x *ConfigIssue) GetActionTitle() string {
+	if x != nil {
+		return x.ActionTitle
+	}
+	return ""
+}
+
+func (x *ConfigIssue) GetArgumentName() string {
+	if x != nil {
+		return x.ArgumentName
+	}
+	return ""
+}
+
+func (x *ConfigIssue) GetSource() string {
+	if x != nil {
+		return x.Source
+	}
+	return ""
+}
+
+func (x *ConfigIssue) GetConfigFile() string {
+	if x != nil {
+		return x.ConfigFile
+	}
+	return ""
+}
+
 type GetDiagnosticsResponse struct {
 type GetDiagnosticsResponse struct {
 	state          protoimpl.MessageState `protogen:"open.v1"`
 	state          protoimpl.MessageState `protogen:"open.v1"`
 	SshFoundKey    string                 `protobuf:"bytes,1,opt,name=SshFoundKey,proto3" json:"SshFoundKey,omitempty"`
 	SshFoundKey    string                 `protobuf:"bytes,1,opt,name=SshFoundKey,proto3" json:"SshFoundKey,omitempty"`
 	SshFoundConfig string                 `protobuf:"bytes,2,opt,name=SshFoundConfig,proto3" json:"SshFoundConfig,omitempty"`
 	SshFoundConfig string                 `protobuf:"bytes,2,opt,name=SshFoundConfig,proto3" json:"SshFoundConfig,omitempty"`
+	ConfigIssues   []*ConfigIssue         `protobuf:"bytes,3,rep,name=config_issues,json=configIssues,proto3" json:"config_issues,omitempty"`
 	unknownFields  protoimpl.UnknownFields
 	unknownFields  protoimpl.UnknownFields
 	sizeCache      protoimpl.SizeCache
 	sizeCache      protoimpl.SizeCache
 }
 }
 
 
 func (x *GetDiagnosticsResponse) Reset() {
 func (x *GetDiagnosticsResponse) Reset() {
 	*x = GetDiagnosticsResponse{}
 	*x = GetDiagnosticsResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[65]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[66]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 	ms.StoreMessageInfo(mi)
 }
 }
@@ -3893,7 +3994,7 @@ func (x *GetDiagnosticsResponse) String() string {
 func (*GetDiagnosticsResponse) ProtoMessage() {}
 func (*GetDiagnosticsResponse) ProtoMessage() {}
 
 
 func (x *GetDiagnosticsResponse) ProtoReflect() protoreflect.Message {
 func (x *GetDiagnosticsResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[65]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[66]
 	if x != nil {
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
 		if ms.LoadMessageInfo() == nil {
@@ -3906,7 +4007,7 @@ func (x *GetDiagnosticsResponse) ProtoReflect() protoreflect.Message {
 
 
 // Deprecated: Use GetDiagnosticsResponse.ProtoReflect.Descriptor instead.
 // Deprecated: Use GetDiagnosticsResponse.ProtoReflect.Descriptor instead.
 func (*GetDiagnosticsResponse) Descriptor() ([]byte, []int) {
 func (*GetDiagnosticsResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{65}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{66}
 }
 }
 
 
 func (x *GetDiagnosticsResponse) GetSshFoundKey() string {
 func (x *GetDiagnosticsResponse) GetSshFoundKey() string {
@@ -3923,6 +4024,13 @@ func (x *GetDiagnosticsResponse) GetSshFoundConfig() string {
 	return ""
 	return ""
 }
 }
 
 
+func (x *GetDiagnosticsResponse) GetConfigIssues() []*ConfigIssue {
+	if x != nil {
+		return x.ConfigIssues
+	}
+	return nil
+}
+
 type InitRequest struct {
 type InitRequest struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	unknownFields protoimpl.UnknownFields
 	unknownFields protoimpl.UnknownFields
@@ -3931,7 +4039,7 @@ type InitRequest struct {
 
 
 func (x *InitRequest) Reset() {
 func (x *InitRequest) Reset() {
 	*x = InitRequest{}
 	*x = InitRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[66]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[67]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 	ms.StoreMessageInfo(mi)
 }
 }
@@ -3943,7 +4051,7 @@ func (x *InitRequest) String() string {
 func (*InitRequest) ProtoMessage() {}
 func (*InitRequest) ProtoMessage() {}
 
 
 func (x *InitRequest) ProtoReflect() protoreflect.Message {
 func (x *InitRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[66]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[67]
 	if x != nil {
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
 		if ms.LoadMessageInfo() == nil {
@@ -3956,7 +4064,7 @@ func (x *InitRequest) ProtoReflect() protoreflect.Message {
 
 
 // Deprecated: Use InitRequest.ProtoReflect.Descriptor instead.
 // Deprecated: Use InitRequest.ProtoReflect.Descriptor instead.
 func (*InitRequest) Descriptor() ([]byte, []int) {
 func (*InitRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{66}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{67}
 }
 }
 
 
 type InitResponse struct {
 type InitResponse struct {
@@ -3986,13 +4094,14 @@ type InitResponse struct {
 	LoginRequired             bool                   `protobuf:"varint,23,opt,name=login_required,json=loginRequired,proto3" json:"login_required,omitempty"`
 	LoginRequired             bool                   `protobuf:"varint,23,opt,name=login_required,json=loginRequired,proto3" json:"login_required,omitempty"`
 	AvailableThemes           []string               `protobuf:"bytes,24,rep,name=available_themes,json=availableThemes,proto3" json:"available_themes,omitempty"` // List of available theme names
 	AvailableThemes           []string               `protobuf:"bytes,24,rep,name=available_themes,json=availableThemes,proto3" json:"available_themes,omitempty"` // List of available theme names
 	ShowNavigateOnStartIcons  bool                   `protobuf:"varint,25,opt,name=show_navigate_on_start_icons,json=showNavigateOnStartIcons,proto3" json:"show_navigate_on_start_icons,omitempty"`
 	ShowNavigateOnStartIcons  bool                   `protobuf:"varint,25,opt,name=show_navigate_on_start_icons,json=showNavigateOnStartIcons,proto3" json:"show_navigate_on_start_icons,omitempty"`
+	ConfigIssueCount          int32                  `protobuf:"varint,26,opt,name=config_issue_count,json=configIssueCount,proto3" json:"config_issue_count,omitempty"`
 	unknownFields             protoimpl.UnknownFields
 	unknownFields             protoimpl.UnknownFields
 	sizeCache                 protoimpl.SizeCache
 	sizeCache                 protoimpl.SizeCache
 }
 }
 
 
 func (x *InitResponse) Reset() {
 func (x *InitResponse) Reset() {
 	*x = InitResponse{}
 	*x = InitResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[67]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[68]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 	ms.StoreMessageInfo(mi)
 }
 }
@@ -4004,7 +4113,7 @@ func (x *InitResponse) String() string {
 func (*InitResponse) ProtoMessage() {}
 func (*InitResponse) ProtoMessage() {}
 
 
 func (x *InitResponse) ProtoReflect() protoreflect.Message {
 func (x *InitResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[67]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[68]
 	if x != nil {
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
 		if ms.LoadMessageInfo() == nil {
@@ -4017,7 +4126,7 @@ func (x *InitResponse) ProtoReflect() protoreflect.Message {
 
 
 // Deprecated: Use InitResponse.ProtoReflect.Descriptor instead.
 // Deprecated: Use InitResponse.ProtoReflect.Descriptor instead.
 func (*InitResponse) Descriptor() ([]byte, []int) {
 func (*InitResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{67}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{68}
 }
 }
 
 
 func (x *InitResponse) GetShowFooter() bool {
 func (x *InitResponse) GetShowFooter() bool {
@@ -4195,6 +4304,13 @@ func (x *InitResponse) GetShowNavigateOnStartIcons() bool {
 	return false
 	return false
 }
 }
 
 
+func (x *InitResponse) GetConfigIssueCount() int32 {
+	if x != nil {
+		return x.ConfigIssueCount
+	}
+	return 0
+}
+
 type AdditionalLink struct {
 type AdditionalLink struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Title         string                 `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
 	Title         string                 `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
@@ -4205,7 +4321,7 @@ type AdditionalLink struct {
 
 
 func (x *AdditionalLink) Reset() {
 func (x *AdditionalLink) Reset() {
 	*x = AdditionalLink{}
 	*x = AdditionalLink{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[68]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 	ms.StoreMessageInfo(mi)
 }
 }
@@ -4217,7 +4333,7 @@ func (x *AdditionalLink) String() string {
 func (*AdditionalLink) ProtoMessage() {}
 func (*AdditionalLink) ProtoMessage() {}
 
 
 func (x *AdditionalLink) ProtoReflect() protoreflect.Message {
 func (x *AdditionalLink) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[68]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
 	if x != nil {
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
 		if ms.LoadMessageInfo() == nil {
@@ -4230,7 +4346,7 @@ func (x *AdditionalLink) ProtoReflect() protoreflect.Message {
 
 
 // Deprecated: Use AdditionalLink.ProtoReflect.Descriptor instead.
 // Deprecated: Use AdditionalLink.ProtoReflect.Descriptor instead.
 func (*AdditionalLink) Descriptor() ([]byte, []int) {
 func (*AdditionalLink) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{68}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{69}
 }
 }
 
 
 func (x *AdditionalLink) GetTitle() string {
 func (x *AdditionalLink) GetTitle() string {
@@ -4258,7 +4374,7 @@ type OAuth2Provider struct {
 
 
 func (x *OAuth2Provider) Reset() {
 func (x *OAuth2Provider) Reset() {
 	*x = OAuth2Provider{}
 	*x = OAuth2Provider{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 	ms.StoreMessageInfo(mi)
 }
 }
@@ -4270,7 +4386,7 @@ func (x *OAuth2Provider) String() string {
 func (*OAuth2Provider) ProtoMessage() {}
 func (*OAuth2Provider) ProtoMessage() {}
 
 
 func (x *OAuth2Provider) ProtoReflect() protoreflect.Message {
 func (x *OAuth2Provider) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70]
 	if x != nil {
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
 		if ms.LoadMessageInfo() == nil {
@@ -4283,7 +4399,7 @@ func (x *OAuth2Provider) ProtoReflect() protoreflect.Message {
 
 
 // Deprecated: Use OAuth2Provider.ProtoReflect.Descriptor instead.
 // Deprecated: Use OAuth2Provider.ProtoReflect.Descriptor instead.
 func (*OAuth2Provider) Descriptor() ([]byte, []int) {
 func (*OAuth2Provider) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{69}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{70}
 }
 }
 
 
 func (x *OAuth2Provider) GetTitle() string {
 func (x *OAuth2Provider) GetTitle() string {
@@ -4316,7 +4432,7 @@ type GetActionBindingRequest struct {
 
 
 func (x *GetActionBindingRequest) Reset() {
 func (x *GetActionBindingRequest) Reset() {
 	*x = GetActionBindingRequest{}
 	*x = GetActionBindingRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 	ms.StoreMessageInfo(mi)
 }
 }
@@ -4328,7 +4444,7 @@ func (x *GetActionBindingRequest) String() string {
 func (*GetActionBindingRequest) ProtoMessage() {}
 func (*GetActionBindingRequest) ProtoMessage() {}
 
 
 func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message {
 func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71]
 	if x != nil {
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
 		if ms.LoadMessageInfo() == nil {
@@ -4341,7 +4457,7 @@ func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message {
 
 
 // Deprecated: Use GetActionBindingRequest.ProtoReflect.Descriptor instead.
 // Deprecated: Use GetActionBindingRequest.ProtoReflect.Descriptor instead.
 func (*GetActionBindingRequest) Descriptor() ([]byte, []int) {
 func (*GetActionBindingRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{70}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{71}
 }
 }
 
 
 func (x *GetActionBindingRequest) GetBindingId() string {
 func (x *GetActionBindingRequest) GetBindingId() string {
@@ -4361,7 +4477,7 @@ type GetActionBindingResponse struct {
 
 
 func (x *GetActionBindingResponse) Reset() {
 func (x *GetActionBindingResponse) Reset() {
 	*x = GetActionBindingResponse{}
 	*x = GetActionBindingResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 	ms.StoreMessageInfo(mi)
 }
 }
@@ -4373,7 +4489,7 @@ func (x *GetActionBindingResponse) String() string {
 func (*GetActionBindingResponse) ProtoMessage() {}
 func (*GetActionBindingResponse) ProtoMessage() {}
 
 
 func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message {
 func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72]
 	if x != nil {
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
 		if ms.LoadMessageInfo() == nil {
@@ -4386,7 +4502,7 @@ func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message {
 
 
 // Deprecated: Use GetActionBindingResponse.ProtoReflect.Descriptor instead.
 // Deprecated: Use GetActionBindingResponse.ProtoReflect.Descriptor instead.
 func (*GetActionBindingResponse) Descriptor() ([]byte, []int) {
 func (*GetActionBindingResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{71}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{72}
 }
 }
 
 
 func (x *GetActionBindingResponse) GetAction() *Action {
 func (x *GetActionBindingResponse) GetAction() *Action {
@@ -4415,7 +4531,7 @@ type GetEntitiesRequest struct {
 
 
 func (x *GetEntitiesRequest) Reset() {
 func (x *GetEntitiesRequest) Reset() {
 	*x = GetEntitiesRequest{}
 	*x = GetEntitiesRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 	ms.StoreMessageInfo(mi)
 }
 }
@@ -4427,7 +4543,7 @@ func (x *GetEntitiesRequest) String() string {
 func (*GetEntitiesRequest) ProtoMessage() {}
 func (*GetEntitiesRequest) ProtoMessage() {}
 
 
 func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message {
 func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73]
 	if x != nil {
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
 		if ms.LoadMessageInfo() == nil {
@@ -4440,7 +4556,7 @@ func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message {
 
 
 // Deprecated: Use GetEntitiesRequest.ProtoReflect.Descriptor instead.
 // Deprecated: Use GetEntitiesRequest.ProtoReflect.Descriptor instead.
 func (*GetEntitiesRequest) Descriptor() ([]byte, []int) {
 func (*GetEntitiesRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{72}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{73}
 }
 }
 
 
 func (x *GetEntitiesRequest) GetEntityType() string {
 func (x *GetEntitiesRequest) GetEntityType() string {
@@ -4480,7 +4596,7 @@ type GetEntitiesResponse struct {
 
 
 func (x *GetEntitiesResponse) Reset() {
 func (x *GetEntitiesResponse) Reset() {
 	*x = GetEntitiesResponse{}
 	*x = GetEntitiesResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 	ms.StoreMessageInfo(mi)
 }
 }
@@ -4492,7 +4608,7 @@ func (x *GetEntitiesResponse) String() string {
 func (*GetEntitiesResponse) ProtoMessage() {}
 func (*GetEntitiesResponse) ProtoMessage() {}
 
 
 func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message {
 func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74]
 	if x != nil {
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
 		if ms.LoadMessageInfo() == nil {
@@ -4505,7 +4621,7 @@ func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message {
 
 
 // Deprecated: Use GetEntitiesResponse.ProtoReflect.Descriptor instead.
 // Deprecated: Use GetEntitiesResponse.ProtoReflect.Descriptor instead.
 func (*GetEntitiesResponse) Descriptor() ([]byte, []int) {
 func (*GetEntitiesResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{73}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{74}
 }
 }
 
 
 func (x *GetEntitiesResponse) GetEntityDefinitions() []*EntityDefinition {
 func (x *GetEntitiesResponse) GetEntityDefinitions() []*EntityDefinition {
@@ -4529,7 +4645,7 @@ type EntityDefinition struct {
 
 
 func (x *EntityDefinition) Reset() {
 func (x *EntityDefinition) Reset() {
 	*x = EntityDefinition{}
 	*x = EntityDefinition{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 	ms.StoreMessageInfo(mi)
 }
 }
@@ -4541,7 +4657,7 @@ func (x *EntityDefinition) String() string {
 func (*EntityDefinition) ProtoMessage() {}
 func (*EntityDefinition) ProtoMessage() {}
 
 
 func (x *EntityDefinition) ProtoReflect() protoreflect.Message {
 func (x *EntityDefinition) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75]
 	if x != nil {
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
 		if ms.LoadMessageInfo() == nil {
@@ -4554,7 +4670,7 @@ func (x *EntityDefinition) ProtoReflect() protoreflect.Message {
 
 
 // Deprecated: Use EntityDefinition.ProtoReflect.Descriptor instead.
 // Deprecated: Use EntityDefinition.ProtoReflect.Descriptor instead.
 func (*EntityDefinition) Descriptor() ([]byte, []int) {
 func (*EntityDefinition) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{74}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{75}
 }
 }
 
 
 func (x *EntityDefinition) GetTitle() string {
 func (x *EntityDefinition) GetTitle() string {
@@ -4609,7 +4725,7 @@ type EntityProperty struct {
 
 
 func (x *EntityProperty) Reset() {
 func (x *EntityProperty) Reset() {
 	*x = EntityProperty{}
 	*x = EntityProperty{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 	ms.StoreMessageInfo(mi)
 }
 }
@@ -4621,7 +4737,7 @@ func (x *EntityProperty) String() string {
 func (*EntityProperty) ProtoMessage() {}
 func (*EntityProperty) ProtoMessage() {}
 
 
 func (x *EntityProperty) ProtoReflect() protoreflect.Message {
 func (x *EntityProperty) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76]
 	if x != nil {
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
 		if ms.LoadMessageInfo() == nil {
@@ -4634,7 +4750,7 @@ func (x *EntityProperty) ProtoReflect() protoreflect.Message {
 
 
 // Deprecated: Use EntityProperty.ProtoReflect.Descriptor instead.
 // Deprecated: Use EntityProperty.ProtoReflect.Descriptor instead.
 func (*EntityProperty) Descriptor() ([]byte, []int) {
 func (*EntityProperty) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{75}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{76}
 }
 }
 
 
 func (x *EntityProperty) GetName() string {
 func (x *EntityProperty) GetName() string {
@@ -4661,7 +4777,7 @@ type GetEntityRequest struct {
 
 
 func (x *GetEntityRequest) Reset() {
 func (x *GetEntityRequest) Reset() {
 	*x = GetEntityRequest{}
 	*x = GetEntityRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 	ms.StoreMessageInfo(mi)
 }
 }
@@ -4673,7 +4789,7 @@ func (x *GetEntityRequest) String() string {
 func (*GetEntityRequest) ProtoMessage() {}
 func (*GetEntityRequest) ProtoMessage() {}
 
 
 func (x *GetEntityRequest) ProtoReflect() protoreflect.Message {
 func (x *GetEntityRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77]
 	if x != nil {
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
 		if ms.LoadMessageInfo() == nil {
@@ -4686,7 +4802,7 @@ func (x *GetEntityRequest) ProtoReflect() protoreflect.Message {
 
 
 // Deprecated: Use GetEntityRequest.ProtoReflect.Descriptor instead.
 // Deprecated: Use GetEntityRequest.ProtoReflect.Descriptor instead.
 func (*GetEntityRequest) Descriptor() ([]byte, []int) {
 func (*GetEntityRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{76}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{77}
 }
 }
 
 
 func (x *GetEntityRequest) GetUniqueKey() string {
 func (x *GetEntityRequest) GetUniqueKey() string {
@@ -4712,7 +4828,7 @@ type RestartActionRequest struct {
 
 
 func (x *RestartActionRequest) Reset() {
 func (x *RestartActionRequest) Reset() {
 	*x = RestartActionRequest{}
 	*x = RestartActionRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[78]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 	ms.StoreMessageInfo(mi)
 }
 }
@@ -4724,7 +4840,7 @@ func (x *RestartActionRequest) String() string {
 func (*RestartActionRequest) ProtoMessage() {}
 func (*RestartActionRequest) ProtoMessage() {}
 
 
 func (x *RestartActionRequest) ProtoReflect() protoreflect.Message {
 func (x *RestartActionRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[78]
 	if x != nil {
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
 		if ms.LoadMessageInfo() == nil {
@@ -4737,7 +4853,7 @@ func (x *RestartActionRequest) ProtoReflect() protoreflect.Message {
 
 
 // Deprecated: Use RestartActionRequest.ProtoReflect.Descriptor instead.
 // Deprecated: Use RestartActionRequest.ProtoReflect.Descriptor instead.
 func (*RestartActionRequest) Descriptor() ([]byte, []int) {
 func (*RestartActionRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{77}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{78}
 }
 }
 
 
 func (x *RestartActionRequest) GetExecutionTrackingId() string {
 func (x *RestartActionRequest) GetExecutionTrackingId() string {
@@ -5044,11 +5160,22 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\x04hash\x18\x01 \x01(\tR\x04hash\"\x0f\n" +
 	"\x04hash\x18\x01 \x01(\tR\x04hash\"\x0f\n" +
 	"\rLogoutRequest\"\x10\n" +
 	"\rLogoutRequest\"\x10\n" +
 	"\x0eLogoutResponse\"\x17\n" +
 	"\x0eLogoutResponse\"\x17\n" +
-	"\x15GetDiagnosticsRequest\"b\n" +
+	"\x15GetDiagnosticsRequest\"\xf5\x01\n" +
+	"\vConfigIssue\x12\x1a\n" +
+	"\bseverity\x18\x01 \x01(\tR\bseverity\x12\x12\n" +
+	"\x04code\x18\x02 \x01(\tR\x04code\x12\x18\n" +
+	"\amessage\x18\x03 \x01(\tR\amessage\x12\x1b\n" +
+	"\taction_id\x18\x04 \x01(\tR\bactionId\x12!\n" +
+	"\faction_title\x18\x05 \x01(\tR\vactionTitle\x12#\n" +
+	"\rargument_name\x18\x06 \x01(\tR\fargumentName\x12\x16\n" +
+	"\x06source\x18\a \x01(\tR\x06source\x12\x1f\n" +
+	"\vconfig_file\x18\b \x01(\tR\n" +
+	"configFile\"\xa5\x01\n" +
 	"\x16GetDiagnosticsResponse\x12 \n" +
 	"\x16GetDiagnosticsResponse\x12 \n" +
 	"\vSshFoundKey\x18\x01 \x01(\tR\vSshFoundKey\x12&\n" +
 	"\vSshFoundKey\x18\x01 \x01(\tR\vSshFoundKey\x12&\n" +
-	"\x0eSshFoundConfig\x18\x02 \x01(\tR\x0eSshFoundConfig\"\r\n" +
-	"\vInitRequest\"\x8d\t\n" +
+	"\x0eSshFoundConfig\x18\x02 \x01(\tR\x0eSshFoundConfig\x12A\n" +
+	"\rconfig_issues\x18\x03 \x03(\v2\x1c.olivetin.api.v1.ConfigIssueR\fconfigIssues\"\r\n" +
+	"\vInitRequest\"\xbb\t\n" +
 	"\fInitResponse\x12\x1e\n" +
 	"\fInitResponse\x12\x1e\n" +
 	"\n" +
 	"\n" +
 	"showFooter\x18\x01 \x01(\bR\n" +
 	"showFooter\x18\x01 \x01(\bR\n" +
@@ -5078,7 +5205,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\rshow_log_list\x18\x16 \x01(\bR\vshowLogList\x12%\n" +
 	"\rshow_log_list\x18\x16 \x01(\bR\vshowLogList\x12%\n" +
 	"\x0elogin_required\x18\x17 \x01(\bR\rloginRequired\x12)\n" +
 	"\x0elogin_required\x18\x17 \x01(\bR\rloginRequired\x12)\n" +
 	"\x10available_themes\x18\x18 \x03(\tR\x0favailableThemes\x12>\n" +
 	"\x10available_themes\x18\x18 \x03(\tR\x0favailableThemes\x12>\n" +
-	"\x1cshow_navigate_on_start_icons\x18\x19 \x01(\bR\x18showNavigateOnStartIcons\"8\n" +
+	"\x1cshow_navigate_on_start_icons\x18\x19 \x01(\bR\x18showNavigateOnStartIcons\x12,\n" +
+	"\x12config_issue_count\x18\x1a \x01(\x05R\x10configIssueCount\"8\n" +
 	"\x0eAdditionalLink\x12\x14\n" +
 	"\x0eAdditionalLink\x12\x14\n" +
 	"\x05title\x18\x01 \x01(\tR\x05title\x12\x10\n" +
 	"\x05title\x18\x01 \x01(\tR\x05title\x12\x10\n" +
 	"\x03url\x18\x02 \x01(\tR\x03url\"L\n" +
 	"\x03url\x18\x02 \x01(\tR\x03url\"L\n" +
@@ -5159,7 +5287,7 @@ func file_olivetin_api_v1_olivetin_proto_rawDescGZIP() []byte {
 	return file_olivetin_api_v1_olivetin_proto_rawDescData
 	return file_olivetin_api_v1_olivetin_proto_rawDescData
 }
 }
 
 
-var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 85)
+var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 86)
 var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
 var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
 	(*Action)(nil),                          // 0: olivetin.api.v1.Action
 	(*Action)(nil),                          // 0: olivetin.api.v1.Action
 	(*ActionGroupMembership)(nil),           // 1: olivetin.api.v1.ActionGroupMembership
 	(*ActionGroupMembership)(nil),           // 1: olivetin.api.v1.ActionGroupMembership
@@ -5226,38 +5354,39 @@ var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
 	(*LogoutRequest)(nil),                   // 62: olivetin.api.v1.LogoutRequest
 	(*LogoutRequest)(nil),                   // 62: olivetin.api.v1.LogoutRequest
 	(*LogoutResponse)(nil),                  // 63: olivetin.api.v1.LogoutResponse
 	(*LogoutResponse)(nil),                  // 63: olivetin.api.v1.LogoutResponse
 	(*GetDiagnosticsRequest)(nil),           // 64: olivetin.api.v1.GetDiagnosticsRequest
 	(*GetDiagnosticsRequest)(nil),           // 64: olivetin.api.v1.GetDiagnosticsRequest
-	(*GetDiagnosticsResponse)(nil),          // 65: olivetin.api.v1.GetDiagnosticsResponse
-	(*InitRequest)(nil),                     // 66: olivetin.api.v1.InitRequest
-	(*InitResponse)(nil),                    // 67: olivetin.api.v1.InitResponse
-	(*AdditionalLink)(nil),                  // 68: olivetin.api.v1.AdditionalLink
-	(*OAuth2Provider)(nil),                  // 69: olivetin.api.v1.OAuth2Provider
-	(*GetActionBindingRequest)(nil),         // 70: olivetin.api.v1.GetActionBindingRequest
-	(*GetActionBindingResponse)(nil),        // 71: olivetin.api.v1.GetActionBindingResponse
-	(*GetEntitiesRequest)(nil),              // 72: olivetin.api.v1.GetEntitiesRequest
-	(*GetEntitiesResponse)(nil),             // 73: olivetin.api.v1.GetEntitiesResponse
-	(*EntityDefinition)(nil),                // 74: olivetin.api.v1.EntityDefinition
-	(*EntityProperty)(nil),                  // 75: olivetin.api.v1.EntityProperty
-	(*GetEntityRequest)(nil),                // 76: olivetin.api.v1.GetEntityRequest
-	(*RestartActionRequest)(nil),            // 77: olivetin.api.v1.RestartActionRequest
-	nil,                                     // 78: olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
-	nil,                                     // 79: olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
-	nil,                                     // 80: olivetin.api.v1.ActionArgument.SuggestionsEntry
-	nil,                                     // 81: olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
-	nil,                                     // 82: olivetin.api.v1.Entity.FieldsEntry
-	nil,                                     // 83: olivetin.api.v1.DumpVarsResponse.ContentsEntry
-	nil,                                     // 84: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
+	(*ConfigIssue)(nil),                     // 65: olivetin.api.v1.ConfigIssue
+	(*GetDiagnosticsResponse)(nil),          // 66: olivetin.api.v1.GetDiagnosticsResponse
+	(*InitRequest)(nil),                     // 67: olivetin.api.v1.InitRequest
+	(*InitResponse)(nil),                    // 68: olivetin.api.v1.InitResponse
+	(*AdditionalLink)(nil),                  // 69: olivetin.api.v1.AdditionalLink
+	(*OAuth2Provider)(nil),                  // 70: olivetin.api.v1.OAuth2Provider
+	(*GetActionBindingRequest)(nil),         // 71: olivetin.api.v1.GetActionBindingRequest
+	(*GetActionBindingResponse)(nil),        // 72: olivetin.api.v1.GetActionBindingResponse
+	(*GetEntitiesRequest)(nil),              // 73: olivetin.api.v1.GetEntitiesRequest
+	(*GetEntitiesResponse)(nil),             // 74: olivetin.api.v1.GetEntitiesResponse
+	(*EntityDefinition)(nil),                // 75: olivetin.api.v1.EntityDefinition
+	(*EntityProperty)(nil),                  // 76: olivetin.api.v1.EntityProperty
+	(*GetEntityRequest)(nil),                // 77: olivetin.api.v1.GetEntityRequest
+	(*RestartActionRequest)(nil),            // 78: olivetin.api.v1.RestartActionRequest
+	nil,                                     // 79: olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
+	nil,                                     // 80: olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
+	nil,                                     // 81: olivetin.api.v1.ActionArgument.SuggestionsEntry
+	nil,                                     // 82: olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
+	nil,                                     // 83: olivetin.api.v1.Entity.FieldsEntry
+	nil,                                     // 84: olivetin.api.v1.DumpVarsResponse.ContentsEntry
+	nil,                                     // 85: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
 }
 }
 var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
 var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
 	3,  // 0: olivetin.api.v1.Action.arguments:type_name -> olivetin.api.v1.ActionArgument
 	3,  // 0: olivetin.api.v1.Action.arguments:type_name -> olivetin.api.v1.ActionArgument
 	2,  // 1: olivetin.api.v1.Action.exec_on_webhooks:type_name -> olivetin.api.v1.ActionWebhookExecHint
 	2,  // 1: olivetin.api.v1.Action.exec_on_webhooks:type_name -> olivetin.api.v1.ActionWebhookExecHint
 	1,  // 2: olivetin.api.v1.Action.groups:type_name -> olivetin.api.v1.ActionGroupMembership
 	1,  // 2: olivetin.api.v1.Action.groups:type_name -> olivetin.api.v1.ActionGroupMembership
-	78, // 3: olivetin.api.v1.ActionWebhookExecHint.match_headers:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
-	79, // 4: olivetin.api.v1.ActionWebhookExecHint.match_query:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
+	79, // 3: olivetin.api.v1.ActionWebhookExecHint.match_headers:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
+	80, // 4: olivetin.api.v1.ActionWebhookExecHint.match_query:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
 	4,  // 5: olivetin.api.v1.ActionArgument.choices:type_name -> olivetin.api.v1.ActionArgumentChoice
 	4,  // 5: olivetin.api.v1.ActionArgument.choices:type_name -> olivetin.api.v1.ActionArgumentChoice
-	80, // 6: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry
+	81, // 6: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry
 	0,  // 7: olivetin.api.v1.EntityRelatedAction.action:type_name -> olivetin.api.v1.Action
 	0,  // 7: olivetin.api.v1.EntityRelatedAction.action:type_name -> olivetin.api.v1.Action
-	81, // 8: olivetin.api.v1.EntityRelatedAction.prefilled_arguments:type_name -> olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
-	82, // 9: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry
+	82, // 8: olivetin.api.v1.EntityRelatedAction.prefilled_arguments:type_name -> olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
+	83, // 9: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry
 	5,  // 10: olivetin.api.v1.Entity.related_actions:type_name -> olivetin.api.v1.EntityRelatedAction
 	5,  // 10: olivetin.api.v1.Entity.related_actions:type_name -> olivetin.api.v1.EntityRelatedAction
 	10, // 11: olivetin.api.v1.GetDashboardResponse.dashboard:type_name -> olivetin.api.v1.Dashboard
 	10, // 11: olivetin.api.v1.GetDashboardResponse.dashboard:type_name -> olivetin.api.v1.Dashboard
 	11, // 12: olivetin.api.v1.Dashboard.contents:type_name -> olivetin.api.v1.DashboardComponent
 	11, // 12: olivetin.api.v1.Dashboard.contents:type_name -> olivetin.api.v1.DashboardComponent
@@ -5275,8 +5404,8 @@ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
 	28, // 24: olivetin.api.v1.GetExecutionQueueResponse.groups:type_name -> olivetin.api.v1.ExecutionQueueGroup
 	28, // 24: olivetin.api.v1.GetExecutionQueueResponse.groups:type_name -> olivetin.api.v1.ExecutionQueueGroup
 	22, // 25: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
 	22, // 25: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
 	35, // 26: olivetin.api.v1.ExecutionStatusResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
 	35, // 26: olivetin.api.v1.ExecutionStatusResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
-	83, // 27: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
-	84, // 28: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
+	84, // 27: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
+	85, // 28: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
 	51, // 29: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged
 	51, // 29: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged
 	52, // 30: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged
 	52, // 30: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged
 	54, // 31: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished
 	54, // 31: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished
@@ -5285,72 +5414,73 @@ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
 	53, // 34: olivetin.api.v1.EventStreamResponse.heartbeat:type_name -> olivetin.api.v1.EventHeartbeat
 	53, // 34: olivetin.api.v1.EventStreamResponse.heartbeat:type_name -> olivetin.api.v1.EventHeartbeat
 	22, // 35: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry
 	22, // 35: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry
 	22, // 36: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry
 	22, // 36: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry
-	69, // 37: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
-	68, // 38: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
-	8,  // 39: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy
-	0,  // 40: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
-	35, // 41: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
-	74, // 42: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
-	6,  // 43: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
-	75, // 44: olivetin.api.v1.EntityDefinition.properties:type_name -> olivetin.api.v1.EntityProperty
-	43, // 45: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
-	9,  // 46: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
-	12, // 47: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
-	15, // 48: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
-	17, // 49: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
-	19, // 50: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
-	77, // 51: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
-	56, // 52: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
-	34, // 53: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
-	21, // 54: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
-	24, // 55: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
-	26, // 56: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest
-	30, // 57: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
-	37, // 58: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
-	39, // 59: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:input_type -> olivetin.api.v1.ServerDiagnosticsRequest
-	41, // 60: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
-	44, // 61: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
-	46, // 62: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
-	58, // 63: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
-	60, // 64: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
-	62, // 65: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
-	48, // 66: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
-	64, // 67: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
-	66, // 68: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
-	70, // 69: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
-	72, // 70: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
-	76, // 71: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
-	7,  // 72: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
-	14, // 73: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
-	16, // 74: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
-	18, // 75: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
-	20, // 76: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
-	14, // 77: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
-	57, // 78: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
-	36, // 79: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
-	23, // 80: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
-	25, // 81: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
-	29, // 82: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse
-	31, // 83: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
-	38, // 84: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
-	40, // 85: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:output_type -> olivetin.api.v1.ServerDiagnosticsResponse
-	42, // 86: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
-	45, // 87: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
-	47, // 88: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
-	59, // 89: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
-	61, // 90: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
-	63, // 91: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
-	49, // 92: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
-	65, // 93: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
-	67, // 94: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
-	71, // 95: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
-	73, // 96: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
-	6,  // 97: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
-	72, // [72:98] is the sub-list for method output_type
-	46, // [46:72] is the sub-list for method input_type
-	46, // [46:46] is the sub-list for extension type_name
-	46, // [46:46] is the sub-list for extension extendee
-	0,  // [0:46] is the sub-list for field type_name
+	65, // 37: olivetin.api.v1.GetDiagnosticsResponse.config_issues:type_name -> olivetin.api.v1.ConfigIssue
+	70, // 38: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
+	69, // 39: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
+	8,  // 40: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy
+	0,  // 41: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
+	35, // 42: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
+	75, // 43: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
+	6,  // 44: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
+	76, // 45: olivetin.api.v1.EntityDefinition.properties:type_name -> olivetin.api.v1.EntityProperty
+	43, // 46: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
+	9,  // 47: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
+	12, // 48: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
+	15, // 49: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
+	17, // 50: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
+	19, // 51: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
+	78, // 52: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
+	56, // 53: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
+	34, // 54: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
+	21, // 55: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
+	24, // 56: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
+	26, // 57: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest
+	30, // 58: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
+	37, // 59: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
+	39, // 60: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:input_type -> olivetin.api.v1.ServerDiagnosticsRequest
+	41, // 61: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
+	44, // 62: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
+	46, // 63: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
+	58, // 64: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
+	60, // 65: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
+	62, // 66: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
+	48, // 67: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
+	64, // 68: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
+	67, // 69: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
+	71, // 70: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
+	73, // 71: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
+	77, // 72: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
+	7,  // 73: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
+	14, // 74: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
+	16, // 75: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
+	18, // 76: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
+	20, // 77: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
+	14, // 78: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
+	57, // 79: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
+	36, // 80: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
+	23, // 81: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
+	25, // 82: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
+	29, // 83: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse
+	31, // 84: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
+	38, // 85: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
+	40, // 86: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:output_type -> olivetin.api.v1.ServerDiagnosticsResponse
+	42, // 87: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
+	45, // 88: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
+	47, // 89: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
+	59, // 90: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
+	61, // 91: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
+	63, // 92: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
+	49, // 93: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
+	66, // 94: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
+	68, // 95: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
+	72, // 96: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
+	74, // 97: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
+	6,  // 98: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
+	73, // [73:99] is the sub-list for method output_type
+	47, // [47:73] is the sub-list for method input_type
+	47, // [47:47] is the sub-list for extension type_name
+	47, // [47:47] is the sub-list for extension extendee
+	0,  // [0:47] is the sub-list for field type_name
 }
 }
 
 
 func init() { file_olivetin_api_v1_olivetin_proto_init() }
 func init() { file_olivetin_api_v1_olivetin_proto_init() }
@@ -5372,7 +5502,7 @@ func file_olivetin_api_v1_olivetin_proto_init() {
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: unsafe.Slice(unsafe.StringData(file_olivetin_api_v1_olivetin_proto_rawDesc), len(file_olivetin_api_v1_olivetin_proto_rawDesc)),
 			RawDescriptor: unsafe.Slice(unsafe.StringData(file_olivetin_api_v1_olivetin_proto_rawDesc), len(file_olivetin_api_v1_olivetin_proto_rawDesc)),
 			NumEnums:      0,
 			NumEnums:      0,
-			NumMessages:   85,
+			NumMessages:   86,
 			NumExtensions: 0,
 			NumExtensions: 0,
 			NumServices:   1,
 			NumServices:   1,
 		},
 		},

+ 2 - 0
service/internal/api/api.go

@@ -1200,6 +1200,7 @@ func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[api
 	res := &apiv1.GetDiagnosticsResponse{
 	res := &apiv1.GetDiagnosticsResponse{
 		SshFoundKey:    installationinfo.Runtime.SshFoundKey,
 		SshFoundKey:    installationinfo.Runtime.SshFoundKey,
 		SshFoundConfig: installationinfo.Runtime.SshFoundConfig,
 		SshFoundConfig: installationinfo.Runtime.SshFoundConfig,
+		ConfigIssues:   api.buildConfigIssuesForUser(user),
 	}
 	}
 	return connect.NewResponse(res), nil
 	return connect.NewResponse(res), nil
 }
 }
@@ -1242,6 +1243,7 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
 		LoginRequired:             loginRequired,
 		LoginRequired:             loginRequired,
 		AvailableThemes:           discoverAvailableThemes(api.cfg),
 		AvailableThemes:           discoverAvailableThemes(api.cfg),
 		ShowNavigateOnStartIcons:  api.cfg.ShowNavigateOnStartIcons,
 		ShowNavigateOnStartIcons:  api.cfg.ShowNavigateOnStartIcons,
+		ConfigIssueCount:          configIssueCountForUser(api, user),
 	}
 	}
 
 
 	return connect.NewResponse(res), nil
 	return connect.NewResponse(res), nil

+ 197 - 0
service/internal/api/api_config_issues_test.go

@@ -0,0 +1,197 @@
+package api
+
+import (
+	"context"
+	"testing"
+
+	"connectrpc.com/connect"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/configissues"
+)
+
+func TestGetDiagnosticsReturnsConfigIssues(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Actions = []*config.Action{
+		{
+			Title:  "Deploy",
+			ID:     "deploy",
+			Entity: "host",
+			Arguments: []config.ActionArgument{
+				{
+					Name:    "ansible_host",
+					Type:    "ascii_identifier",
+					Default: `{{ host.name | default "All" }}`,
+				},
+			},
+			ExecOnCron: []string{"0 1 * * *"},
+		},
+	}
+
+	ts, client := getNewTestServerAndClient(cfg)
+	defer ts.Close()
+
+	res, err := client.GetDiagnostics(context.Background(), connect.NewRequest(&apiv1.GetDiagnosticsRequest{}))
+	require.NoError(t, err)
+	require.NotNil(t, res.Msg)
+	require.NotEmpty(t, res.Msg.ConfigIssues)
+
+	codes := map[string]bool{}
+	for _, issue := range res.Msg.ConfigIssues {
+		codes[issue.Code] = true
+	}
+	assert.True(t, codes[configissues.CodeTemplateParse])
+	assert.True(t, codes[configissues.CodeCronEntityBinding])
+
+	initRes, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
+	require.NoError(t, err)
+	assert.Equal(t, int32(len(res.Msg.ConfigIssues)), initRes.Msg.ConfigIssueCount)
+}
+
+func TestInitHidesConfigIssueCountWithoutDiagnostics(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.DefaultPolicy.ShowDiagnostics = false
+	cfg.Actions = []*config.Action{
+		{
+			Title:  "Deploy",
+			ID:     "deploy",
+			Entity: "host",
+			Arguments: []config.ActionArgument{
+				{
+					Name:    "ansible_host",
+					Type:    "ascii_identifier",
+					Default: `{{ host.name | default "All" }}`,
+				},
+			},
+		},
+	}
+
+	ts, client := getNewTestServerAndClient(cfg)
+	defer ts.Close()
+
+	initRes, err := client.Init(context.Background(), connect.NewRequest(&apiv1.InitRequest{}))
+	require.NoError(t, err)
+	assert.Equal(t, int32(0), initRes.Msg.ConfigIssueCount)
+
+	_, err = client.GetDiagnostics(context.Background(), connect.NewRequest(&apiv1.GetDiagnosticsRequest{}))
+	require.Error(t, err)
+	assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err))
+}
+
+func attachBrokenTemplateArgToSecretAction(cfg *config.Config) {
+	for i := range cfg.Actions {
+		if cfg.Actions[i].ID != "secret_action" {
+			continue
+		}
+		cfg.Actions[i].Arguments = []config.ActionArgument{
+			{
+				Name:    "target",
+				Type:    "ascii_identifier",
+				Default: `{{ host.name | default "All" }}`,
+			},
+		}
+		return
+	}
+}
+
+func assertNoActionScopedConfigIssues(t *testing.T, issues []*apiv1.ConfigIssue) {
+	t.Helper()
+	for _, issue := range issues {
+		assert.Empty(t, issue.ActionId,
+			"user with view:false must not see action-scoped config issues")
+	}
+}
+
+func configIssuesContainActionID(issues []*apiv1.ConfigIssue, actionID string) bool {
+	for _, issue := range issues {
+		if issue.ActionId == actionID {
+			return true
+		}
+	}
+	return false
+}
+
+func TestConfigIssuesHideActionsWithoutViewPermission(t *testing.T) {
+	cfg, _, _ := buildViewPermissionTestConfig(t)
+	cfg.AuthHttpHeaderUsername = "X-Ot-User"
+	attachBrokenTemplateArgToSecretAction(cfg)
+
+	ts, client := getNewTestServerAndClient(cfg)
+	defer ts.Close()
+
+	configissues.Report(configissues.Issue{
+		Severity: configissues.SeverityWarning,
+		Code:     configissues.CodeEnvUnset,
+		Message:  "TEST_GLOBAL_ENV is not set",
+		Source:   "TEST_GLOBAL_ENV",
+	})
+
+	lowReq := connect.NewRequest(&apiv1.GetDiagnosticsRequest{})
+	lowReq.Header().Set("X-Ot-User", "low")
+	lowRes, err := client.GetDiagnostics(context.Background(), lowReq)
+	require.NoError(t, err)
+
+	assertNoActionScopedConfigIssues(t, lowRes.Msg.ConfigIssues)
+	require.NotEmpty(t, lowRes.Msg.ConfigIssues, "low user should still see global config issues")
+
+	lowInit := connect.NewRequest(&apiv1.InitRequest{})
+	lowInit.Header().Set("X-Ot-User", "low")
+	lowInitRes, err := client.Init(context.Background(), lowInit)
+	require.NoError(t, err)
+	assert.Equal(t, int32(len(lowRes.Msg.ConfigIssues)), lowInitRes.Msg.ConfigIssueCount)
+
+	adminReq := connect.NewRequest(&apiv1.GetDiagnosticsRequest{})
+	adminReq.Header().Set("X-Ot-User", "admin")
+	adminRes, err := client.GetDiagnostics(context.Background(), adminReq)
+	require.NoError(t, err)
+
+	assert.True(t, configIssuesContainActionID(adminRes.Msg.ConfigIssues, "secret_action"),
+		"admin must still see config issues for secret_action")
+	assert.Greater(t, len(adminRes.Msg.ConfigIssues), len(lowRes.Msg.ConfigIssues))
+}
+
+func TestConfigIssuesHideRuntimeWatcherFailuresWithoutViewPermission(t *testing.T) {
+	cfg, _, _ := buildViewPermissionTestConfig(t)
+	cfg.AuthHttpHeaderUsername = "X-Ot-User"
+
+	ts, client := getNewTestServerAndClient(cfg)
+	defer ts.Close()
+
+	configissues.Report(configissues.Issue{
+		Severity:    configissues.SeverityError,
+		Code:        configissues.CodeWatcherPath,
+		Message:     `Could not create watcher for "/secret/path": permission denied`,
+		ActionID:    "secret_action",
+		ActionTitle: "Secret Action",
+		Source:      "/secret/path",
+		ConfigFile:  "/etc/OliveTin/secret.yaml",
+	})
+
+	lowReq := connect.NewRequest(&apiv1.GetDiagnosticsRequest{})
+	lowReq.Header().Set("X-Ot-User", "low")
+	lowRes, err := client.GetDiagnostics(context.Background(), lowReq)
+	require.NoError(t, err)
+
+	for _, issue := range lowRes.Msg.ConfigIssues {
+		assert.NotEqual(t, configissues.CodeWatcherPath, issue.Code,
+			"user with view:false must not see action-scoped watcher_path issues")
+		assert.NotContains(t, issue.Source, "/secret/path")
+	}
+
+	adminReq := connect.NewRequest(&apiv1.GetDiagnosticsRequest{})
+	adminReq.Header().Set("X-Ot-User", "admin")
+	adminRes, err := client.GetDiagnostics(context.Background(), adminReq)
+	require.NoError(t, err)
+
+	foundWatcher := false
+	for _, issue := range adminRes.Msg.ConfigIssues {
+		if issue.Code == configissues.CodeWatcherPath && issue.ActionId == "secret_action" {
+			foundWatcher = true
+			break
+		}
+	}
+	assert.True(t, foundWatcher, "admin must see action-scoped watcher_path issues")
+}

+ 75 - 0
service/internal/api/config_issues.go

@@ -0,0 +1,75 @@
+package api
+
+import (
+	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	"github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/configissues"
+)
+
+func configIssueCountForUser(api *oliveTinAPI, user *authpublic.AuthenticatedUser) int32 {
+	return int32(len(api.buildConfigIssuesForUser(user)))
+}
+
+func (api *oliveTinAPI) buildConfigIssuesForUser(user *authpublic.AuthenticatedUser) []*apiv1.ConfigIssue {
+	if !userCanSeeDiagnostics(user) {
+		return []*apiv1.ConfigIssue{}
+	}
+
+	return filterConfigIssuesForUser(api, user, configissues.List())
+}
+
+func userCanSeeDiagnostics(user *authpublic.AuthenticatedUser) bool {
+	return user != nil && user.EffectivePolicy != nil && user.EffectivePolicy.ShowDiagnostics
+}
+
+func filterConfigIssuesForUser(api *oliveTinAPI, user *authpublic.AuthenticatedUser, src []configissues.Issue) []*apiv1.ConfigIssue {
+	out := make([]*apiv1.ConfigIssue, 0, len(src))
+	for _, issue := range src {
+		if api.includeConfigIssueForUser(user, issue) {
+			out = append(out, toProtoConfigIssue(issue))
+		}
+	}
+	return out
+}
+
+func (api *oliveTinAPI) includeConfigIssueForUser(user *authpublic.AuthenticatedUser, issue configissues.Issue) bool {
+	if issue.ActionID == "" {
+		return true
+	}
+	return api.canViewActionID(user, issue.ActionID)
+}
+
+func (api *oliveTinAPI) canViewActionID(user *authpublic.AuthenticatedUser, actionID string) bool {
+	action := findActionInList(api.cfg.Actions, actionID)
+	if action == nil {
+		return false
+	}
+	return api.userCanViewAction(user, action)
+}
+
+func findActionInList(actions []*config.Action, actionID string) *config.Action {
+	for _, action := range actions {
+		if actionHasID(action, actionID) {
+			return action
+		}
+	}
+	return nil
+}
+
+func actionHasID(action *config.Action, actionID string) bool {
+	return action != nil && action.ID == actionID
+}
+
+func toProtoConfigIssue(issue configissues.Issue) *apiv1.ConfigIssue {
+	return &apiv1.ConfigIssue{
+		Severity:     issue.Severity,
+		Code:         issue.Code,
+		Message:      issue.Message,
+		ActionId:     issue.ActionID,
+		ActionTitle:  issue.ActionTitle,
+		ArgumentName: issue.ArgumentName,
+		Source:       issue.Source,
+		ConfigFile:   issue.ConfigFile,
+	}
+}

+ 4 - 0
service/internal/config/config.go

@@ -39,6 +39,8 @@ type Action struct {
 	EnabledExpression      string           `koanf:"enabledExpression"`
 	EnabledExpression      string           `koanf:"enabledExpression"`
 	Groups                 []string         `koanf:"groups"`
 	Groups                 []string         `koanf:"groups"`
 	Justification          string           `koanf:"justification"`
 	Justification          string           `koanf:"justification"`
+	// SourceFile is set by OliveTin when loading config (not user YAML).
+	SourceFile string `koanf:"x-olivetin-source-file"`
 }
 }
 
 
 func (action *Action) RequiresJustification() bool {
 func (action *Action) RequiresJustification() bool {
@@ -110,6 +112,8 @@ type EntityFile struct {
 	Name       string           `koanf:"name"`
 	Name       string           `koanf:"name"`
 	Icon       string           `koanf:"icon"`
 	Icon       string           `koanf:"icon"`
 	Properties []EntityProperty `koanf:"properties"`
 	Properties []EntityProperty `koanf:"properties"`
+	// SourceFile is set by OliveTin when loading config (not user YAML).
+	SourceFile string `koanf:"x-olivetin-source-file"`
 }
 }
 
 
 // EntityProperty defines a column shown when listing entity instances in the UI.
 // EntityProperty defines a column shown when listing entity instances in the UI.

+ 30 - 11
service/internal/config/config_reloader.go

@@ -1,6 +1,7 @@
 package config
 package config
 
 
 import (
 import (
+	"fmt"
 	"os"
 	"os"
 	"path/filepath"
 	"path/filepath"
 	"reflect"
 	"reflect"
@@ -8,6 +9,7 @@ import (
 	"sort"
 	"sort"
 	"strings"
 	"strings"
 
 
+	"github.com/OliveTin/OliveTin/internal/configissues"
 	"github.com/go-viper/mapstructure/v2"
 	"github.com/go-viper/mapstructure/v2"
 	"github.com/knadh/koanf/parsers/yaml"
 	"github.com/knadh/koanf/parsers/yaml"
 	"github.com/knadh/koanf/providers/file"
 	"github.com/knadh/koanf/providers/file"
@@ -40,6 +42,8 @@ func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) {
 		"configPath": configPath,
 		"configPath": configPath,
 	}).Info("Appending cfg source")
 	}).Info("Appending cfg source")
 
 
+	configissues.BeginConfigLoad()
+	stampLoadedConfigSources(k, configPath)
 	loadIncludedConfigsFromDir(k, configPath)
 	loadIncludedConfigsFromDir(k, configPath)
 
 
 	if !unmarshalRoot(k, cfg) {
 	if !unmarshalRoot(k, cfg) {
@@ -172,7 +176,7 @@ func listYamlFiles(includePath string) ([]string, bool) {
 func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
 func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
 	filePath := filepath.Join(includePath, filename)
 	filePath := filepath.Join(includePath, filename)
 
 
-	if err := k.Load(file.Provider(filePath), yaml.Parser(), koanf.WithMergeFunc(mergeFunc)); err != nil {
+	if err := k.Load(file.Provider(filePath), yaml.Parser(), koanf.WithMergeFunc(mergeFuncForSource(filePath))); err != nil {
 		log.Errorf("Error loading included config file %s: %v", filePath, err)
 		log.Errorf("Error loading included config file %s: %v", filePath, err)
 		return
 		return
 	}
 	}
@@ -182,8 +186,15 @@ func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
 	}).Info("Successfully loaded included config file")
 	}).Info("Successfully loaded included config file")
 }
 }
 
 
+func mergeFuncForSource(sourceFile string) func(src, dest map[string]interface{}) error {
+	return func(src map[string]interface{}, dest map[string]interface{}) error {
+		return mergeFunc(src, dest, sourceFile)
+	}
+}
+
 // mergeActionsWhenBothExist merges actions when both src and dest have actions.
 // mergeActionsWhenBothExist merges actions when both src and dest have actions.
-func mergeActionsWhenBothExist(srcActions interface{}, destActions interface{}, dest map[string]interface{}) {
+func mergeActionsWhenBothExist(srcActions interface{}, destActions interface{}, dest map[string]interface{}, sourceFile string) {
+	stampSourceOnMaps(srcActions, sourceFile)
 	srcSlice, ok1 := srcActions.([]interface{})
 	srcSlice, ok1 := srcActions.([]interface{})
 	destSlice, ok2 := destActions.([]interface{})
 	destSlice, ok2 := destActions.([]interface{})
 	if ok1 && ok2 {
 	if ok1 && ok2 {
@@ -194,10 +205,11 @@ func mergeActionsWhenBothExist(srcActions interface{}, destActions interface{},
 }
 }
 
 
 // mergeActionsFromSource merges actions from source into destination.
 // mergeActionsFromSource merges actions from source into destination.
-func mergeActionsFromSource(srcActions interface{}, dest map[string]interface{}) {
+func mergeActionsFromSource(srcActions interface{}, dest map[string]interface{}, sourceFile string) {
 	if destActions, ok := dest["actions"]; ok {
 	if destActions, ok := dest["actions"]; ok {
-		mergeActionsWhenBothExist(srcActions, destActions, dest)
+		mergeActionsWhenBothExist(srcActions, destActions, dest, sourceFile)
 	} else {
 	} else {
+		stampSourceOnMaps(srcActions, sourceFile)
 		dest["actions"] = srcActions
 		dest["actions"] = srcActions
 	}
 	}
 }
 }
@@ -223,7 +235,8 @@ func mergeDashboardsFromSource(srcDashboards interface{}, dest map[string]interf
 }
 }
 
 
 // mergeEntitiesWhenBothExist merges entities when both src and dest have entities.
 // mergeEntitiesWhenBothExist merges entities when both src and dest have entities.
-func mergeEntitiesWhenBothExist(srcEntities interface{}, destEntities interface{}, dest map[string]interface{}) {
+func mergeEntitiesWhenBothExist(srcEntities interface{}, destEntities interface{}, dest map[string]interface{}, sourceFile string) {
+	stampSourceOnMaps(srcEntities, sourceFile)
 	srcSlice, ok1 := srcEntities.([]interface{})
 	srcSlice, ok1 := srcEntities.([]interface{})
 	destSlice, ok2 := destEntities.([]interface{})
 	destSlice, ok2 := destEntities.([]interface{})
 	if ok1 && ok2 {
 	if ok1 && ok2 {
@@ -234,17 +247,18 @@ func mergeEntitiesWhenBothExist(srcEntities interface{}, destEntities interface{
 }
 }
 
 
 // mergeEntitiesFromSource merges entities from source into destination.
 // mergeEntitiesFromSource merges entities from source into destination.
-func mergeEntitiesFromSource(srcEntities interface{}, dest map[string]interface{}) {
+func mergeEntitiesFromSource(srcEntities interface{}, dest map[string]interface{}, sourceFile string) {
 	if destEntities, ok := dest["entities"]; ok {
 	if destEntities, ok := dest["entities"]; ok {
-		mergeEntitiesWhenBothExist(srcEntities, destEntities, dest)
+		mergeEntitiesWhenBothExist(srcEntities, destEntities, dest, sourceFile)
 	} else {
 	} else {
+		stampSourceOnMaps(srcEntities, sourceFile)
 		dest["entities"] = srcEntities
 		dest["entities"] = srcEntities
 	}
 	}
 }
 }
 
 
-func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
+func mergeFunc(src map[string]interface{}, dest map[string]interface{}, sourceFile string) error {
 	if srcActions, ok := src["actions"]; ok {
 	if srcActions, ok := src["actions"]; ok {
-		mergeActionsFromSource(srcActions, dest)
+		mergeActionsFromSource(srcActions, dest, sourceFile)
 	}
 	}
 
 
 	if srcDashboards, ok := src["dashboards"]; ok {
 	if srcDashboards, ok := src["dashboards"]; ok {
@@ -252,7 +266,7 @@ func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
 	}
 	}
 
 
 	if srcEntities, ok := src["entities"]; ok {
 	if srcEntities, ok := src["entities"]; ok {
-		mergeEntitiesFromSource(srcEntities, dest)
+		mergeEntitiesFromSource(srcEntities, dest, sourceFile)
 	}
 	}
 
 
 	return nil
 	return nil
@@ -285,7 +299,12 @@ func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error
 		val, set := os.LookupEnv(key)
 		val, set := os.LookupEnv(key)
 		log.Debugf("Environment variable %q: set=%v, value=%q", key, set, val)
 		log.Debugf("Environment variable %q: set=%v, value=%q", key, set, val)
 		if !set {
 		if !set {
-			log.Warnf("Config file references unset environment variable: \"%s\"", key)
+			configissues.ReportSticky(configissues.Issue{
+				Severity: configissues.SeverityWarning,
+				Code:     configissues.CodeEnvUnset,
+				Message:  fmt.Sprintf("Config file references unset environment variable %q", key),
+				Source:   key,
+			})
 		}
 		}
 		return val
 		return val
 	})
 	})

+ 18 - 65
service/internal/config/sanitize.go

@@ -5,6 +5,7 @@ import (
 	"strings"
 	"strings"
 	"text/template"
 	"text/template"
 
 
+	"github.com/OliveTin/OliveTin/internal/configissues"
 	"github.com/OliveTin/OliveTin/internal/env"
 	"github.com/OliveTin/OliveTin/internal/env"
 	"github.com/google/uuid"
 	"github.com/google/uuid"
 	log "github.com/sirupsen/logrus"
 	log "github.com/sirupsen/logrus"
@@ -29,7 +30,6 @@ func (cfg *Config) Sanitize() {
 	cfg.sanitizeDashboardsForInlineActions()
 	cfg.sanitizeDashboardsForInlineActions()
 
 
 	cfg.sanitizeActionGroups()
 	cfg.sanitizeActionGroups()
-	cfg.sanitizeActionGroupReferences()
 	cfg.sanitizeEntities()
 	cfg.sanitizeEntities()
 
 
 	if err := cfg.validateReservedActionArgumentNames(); err != nil {
 	if err := cfg.validateReservedActionArgumentNames(); err != nil {
@@ -238,7 +238,7 @@ func (action *Action) sanitize(cfg *Config) {
 	action.Groups = dedupeStrings(action.Groups)
 	action.Groups = dedupeStrings(action.Groups)
 
 
 	for idx := range action.Arguments {
 	for idx := range action.Arguments {
-		action.Arguments[idx].sanitize()
+		action.Arguments[idx].sanitize(action)
 	}
 	}
 }
 }
 
 
@@ -283,14 +283,6 @@ func (cfg *Config) sanitizeActionGroups() {
 	}
 	}
 }
 }
 
 
-func (cfg *Config) sanitizeActionGroupReferences() {
-	for _, action := range cfg.Actions {
-		for _, groupName := range action.Groups {
-			cfg.warnInvalidActionGroupReference(action, groupName)
-		}
-	}
-}
-
 func (cfg *Config) sanitizeEntities() {
 func (cfg *Config) sanitizeEntities() {
 	for _, entityFile := range cfg.Entities {
 	for _, entityFile := range cfg.Entities {
 		if entityFile == nil {
 		if entityFile == nil {
@@ -310,24 +302,6 @@ func sanitizeEntityProperties(entityFile *EntityFile) {
 	}
 	}
 }
 }
 
 
-func (cfg *Config) warnInvalidActionGroupReference(action *Action, groupName string) {
-	group, found := cfg.ActionGroups[groupName]
-	if !found {
-		log.WithFields(log.Fields{
-			"actionTitle": action.Title,
-			"groupName":   groupName,
-		}).Warn("Action references unknown action group")
-		return
-	}
-
-	if group == nil || group.MaxConcurrent < 1 {
-		log.WithFields(log.Fields{
-			"actionTitle": action.Title,
-			"groupName":   groupName,
-		}).Warn("Action references action group that will not be enforced at runtime")
-	}
-}
-
 func (cfg *Config) sanitizeAuthRequireGuestsToLogin() {
 func (cfg *Config) sanitizeAuthRequireGuestsToLogin() {
 	if cfg.AuthRequireGuestsToLogin {
 	if cfg.AuthRequireGuestsToLogin {
 		log.Infof("AuthRequireGuestsToLogin is enabled. All defaultPermissions will be set to false")
 		log.Infof("AuthRequireGuestsToLogin is enabled. All defaultPermissions will be set to false")
@@ -505,7 +479,7 @@ func (cfg *Config) sanitizeOnClickDefaults() {
 	cfg.DefaultPopupOnStart = cfg.DefaultOnClick
 	cfg.DefaultPopupOnStart = cfg.DefaultOnClick
 }
 }
 
 
-func (arg *ActionArgument) sanitize() {
+func (arg *ActionArgument) sanitize(action *Action) {
 	if arg.Title == "" {
 	if arg.Title == "" {
 		arg.Title = arg.Name
 		arg.Title = arg.Name
 	}
 	}
@@ -516,45 +490,24 @@ func (arg *ActionArgument) sanitize() {
 		}
 		}
 	}
 	}
 
 
-	arg.sanitizeNoType()
-	arg.sanitizeChecklist()
-
-	// Default value validation runs in executor at config load (validateArgumentDefaults).
-}
-
-func (arg *ActionArgument) sanitizeChecklist() {
-	if arg.Type != "checklist" {
-		return
-	}
-
-	arg.warnMissingChecklistChoices()
-	arg.warnInvalidChecklistEntityTemplate()
-}
-
-func (arg *ActionArgument) warnMissingChecklistChoices() {
-	if len(arg.Choices) == 0 {
-		log.WithFields(log.Fields{
-			"arg": arg.Name,
-		}).Warn("Checklist argument has no choices defined")
-	}
-}
-
-func (arg *ActionArgument) warnInvalidChecklistEntityTemplate() {
-	if arg.Entity == "" || len(arg.Choices) == 1 {
-		return
-	}
-
-	log.WithFields(log.Fields{
-		"arg":    arg.Name,
-		"entity": arg.Entity,
-	}).Warn("Checklist argument with entity should define exactly one choice template")
+	arg.sanitizeNoType(action)
+	// Checklist configuration problems are reported by configcheck.
 }
 }
 
 
-func (arg *ActionArgument) sanitizeNoType() {
+func (arg *ActionArgument) sanitizeNoType(action *Action) {
 	if len(arg.Choices) == 0 && arg.Type == "" {
 	if len(arg.Choices) == 0 && arg.Type == "" {
-		log.WithFields(log.Fields{
-			"arg": arg.Name,
-		}).Warn("Argument type isn't set, will default to 'ascii' but this may not be safe. You should set a type specifically.")
+		issue := configissues.Issue{
+			Severity:     configissues.SeverityWarning,
+			Code:         configissues.CodeArgTypeUnset,
+			Message:      "Argument type isn't set, will default to 'ascii' but this may not be safe",
+			ArgumentName: arg.Name,
+		}
+		if action != nil {
+			issue.ActionID = action.ID
+			issue.ActionTitle = action.Title
+			issue.ConfigFile = action.SourceFile
+		}
+		configissues.ReportSticky(issue)
 		arg.Type = "ascii"
 		arg.Type = "ascii"
 	}
 	}
 }
 }

+ 39 - 0
service/internal/config/source_file.go

@@ -0,0 +1,39 @@
+package config
+
+import "github.com/knadh/koanf/v2"
+
+const sourceFileKey = "x-olivetin-source-file"
+
+// stampSourceOnMaps sets the OliveTin source-file marker on each map in a
+// koanf slice value (actions or entities).
+func stampSourceOnMaps(raw any, sourceFile string) any {
+	if sourceFile == "" {
+		return raw
+	}
+
+	items, ok := raw.([]interface{})
+	if !ok {
+		return raw
+	}
+
+	for _, item := range items {
+		stampSourceOnMap(item, sourceFile)
+	}
+	return items
+}
+
+func stampSourceOnMap(item any, sourceFile string) {
+	m, ok := item.(map[string]interface{})
+	if !ok {
+		return
+	}
+	if existing, ok := m[sourceFileKey].(string); ok && existing != "" {
+		return
+	}
+	m[sourceFileKey] = sourceFile
+}
+
+func stampLoadedConfigSources(k *koanf.Koanf, configPath string) {
+	_ = k.Set("actions", stampSourceOnMaps(k.Get("actions"), configPath))
+	_ = k.Set("entities", stampSourceOnMaps(k.Get("entities"), configPath))
+}

+ 51 - 0
service/internal/config/source_file_test.go

@@ -0,0 +1,51 @@
+package config_test
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/OliveTin/OliveTin/internal/config"
+	"github.com/knadh/koanf/parsers/yaml"
+	"github.com/knadh/koanf/providers/file"
+	"github.com/knadh/koanf/v2"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestAppendSourceStampsActionSourceFiles(t *testing.T) {
+	dir := t.TempDir()
+	includeDir := filepath.Join(dir, "config.d")
+	require.NoError(t, os.Mkdir(includeDir, 0o755))
+
+	basePath := filepath.Join(dir, "config.yaml")
+	require.NoError(t, os.WriteFile(basePath, []byte(`
+include: config.d
+actions:
+  - title: From base
+    shell: echo base
+`), 0o644))
+
+	includePath := filepath.Join(includeDir, "10-extra.yaml")
+	require.NoError(t, os.WriteFile(includePath, []byte(`
+actions:
+  - title: From include
+    shell: echo include
+entities:
+  - name: host
+    file: hosts.yaml
+`), 0o644))
+
+	k := koanf.New(".")
+	require.NoError(t, k.Load(file.Provider(basePath), yaml.Parser()))
+
+	cfg := config.DefaultConfig()
+	config.AppendSource(cfg, k, basePath)
+
+	require.Len(t, cfg.Actions, 2)
+	assert.Equal(t, basePath, cfg.Actions[0].SourceFile)
+	assert.Equal(t, includePath, cfg.Actions[1].SourceFile)
+
+	require.Len(t, cfg.Entities, 1)
+	assert.Equal(t, includePath, cfg.Entities[0].SourceFile)
+}

+ 469 - 0
service/internal/configcheck/rebuild.go

@@ -0,0 +1,469 @@
+package configcheck
+
+import (
+	"bytes"
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"strings"
+
+	"github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/configissues"
+	"github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/OliveTin/OliveTin/internal/tpl"
+	"github.com/robfig/cron/v3"
+	"gopkg.in/yaml.v3"
+)
+
+// Rebuild clears and rebuilds the issue list from the current config, entity
+// state, sticky load-time reports, and any extra issues provided by callers
+// (for example literal argument default validation from the executor).
+func Rebuild(cfg *config.Config, extra ...configissues.Issue) {
+	if cfg == nil {
+		configissues.Replace(extra)
+		return
+	}
+
+	collected := make([]configissues.Issue, 0)
+	collected = append(collected, configissues.CopySticky()...)
+	collected = append(collected, collectActionGroupIssues(cfg)...)
+	collected = append(collected, collectArgumentIssues(cfg)...)
+	collected = append(collected, collectIncludeIssues(cfg)...)
+	collected = append(collected, collectTemplateParseIssues(cfg)...)
+	collected = append(collected, collectEntityFileIssues(cfg)...)
+	collected = append(collected, collectEntityEmptyIssues(cfg)...)
+	collected = append(collected, collectCronIssues(cfg)...)
+	collected = append(collected, collectWatcherPathIssues(cfg)...)
+	collected = append(collected, extra...)
+	configissues.Replace(collected)
+}
+
+func collectActionGroupIssues(cfg *config.Config) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+	for _, action := range cfg.Actions {
+		if action == nil {
+			continue
+		}
+		for _, groupName := range action.Groups {
+			out = append(out, actionGroupIssue(cfg, action, groupName)...)
+		}
+	}
+	return out
+}
+
+func actionGroupIssue(cfg *config.Config, action *config.Action, groupName string) []configissues.Issue {
+	group, found := cfg.ActionGroups[groupName]
+	if !found {
+		return []configissues.Issue{actionIssue(action, configissues.SeverityWarning, configissues.CodeActionGroupUnknown,
+			fmt.Sprintf("Action references unknown action group %q", groupName), groupName, "")}
+	}
+
+	if group == nil || group.MaxConcurrent < 1 {
+		return []configissues.Issue{actionIssue(action, configissues.SeverityWarning, configissues.CodeActionGroupUnenforced,
+			fmt.Sprintf("Action references action group %q that will not be enforced at runtime", groupName), groupName, "")}
+	}
+
+	return nil
+}
+
+func actionIssue(action *config.Action, severity, code, message, source, argName string) configissues.Issue {
+	return configissues.Issue{
+		Severity:     severity,
+		Code:         code,
+		Message:      message,
+		ActionID:     action.ID,
+		ActionTitle:  action.Title,
+		ArgumentName: argName,
+		Source:       source,
+		ConfigFile:   action.SourceFile,
+	}
+}
+
+func collectArgumentIssues(cfg *config.Config) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+	for _, action := range cfg.Actions {
+		if action == nil {
+			continue
+		}
+		for i := range action.Arguments {
+			out = append(out, argumentIssues(action, &action.Arguments[i])...)
+		}
+	}
+	return out
+}
+
+func argumentIssues(action *config.Action, arg *config.ActionArgument) []configissues.Issue {
+	if arg.Type != "checklist" {
+		return nil
+	}
+
+	out := make([]configissues.Issue, 0)
+	out = append(out, checklistNoChoicesIssue(action, arg)...)
+	out = append(out, checklistEntityChoicesIssue(action, arg)...)
+	return out
+}
+
+func checklistNoChoicesIssue(action *config.Action, arg *config.ActionArgument) []configissues.Issue {
+	if len(arg.Choices) > 0 {
+		return nil
+	}
+
+	return []configissues.Issue{actionIssue(action, configissues.SeverityWarning, configissues.CodeChecklistNoChoices,
+		"Checklist argument has no choices defined", "", arg.Name)}
+}
+
+func checklistEntityChoicesIssue(action *config.Action, arg *config.ActionArgument) []configissues.Issue {
+	if arg.Entity == "" || len(arg.Choices) == 1 {
+		return nil
+	}
+
+	return []configissues.Issue{actionIssue(action, configissues.SeverityWarning, configissues.CodeChecklistEntityChoices,
+		"Checklist argument with entity should define exactly one choice template", arg.Entity, arg.Name)}
+}
+
+func collectIncludeIssues(cfg *config.Config) []configissues.Issue {
+	if cfg.Include == "" {
+		return nil
+	}
+
+	includePath := filepath.Join(cfg.GetDir(), cfg.Include)
+	info, err := os.Stat(includePath)
+	if err != nil {
+		return []configissues.Issue{{
+			Severity:   configissues.SeverityWarning,
+			Code:       configissues.CodeIncludeMissing,
+			Message:    fmt.Sprintf("Include directory not found: %s", includePath),
+			Source:     includePath,
+			ConfigFile: includePath,
+		}}
+	}
+
+	if !info.IsDir() {
+		return []configissues.Issue{{
+			Severity:   configissues.SeverityWarning,
+			Code:       configissues.CodeIncludeMissing,
+			Message:    fmt.Sprintf("Include path is not a directory: %s", includePath),
+			Source:     includePath,
+			ConfigFile: includePath,
+		}}
+	}
+
+	return nil
+}
+
+func collectTemplateParseIssues(cfg *config.Config) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+	for _, action := range cfg.Actions {
+		if action == nil {
+			continue
+		}
+		for i := range action.Arguments {
+			arg := &action.Arguments[i]
+			out = append(out, templateIssuesForArg(action, arg)...)
+		}
+	}
+	return out
+}
+
+func templateIssuesForArg(action *config.Action, arg *config.ActionArgument) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+
+	if looksLikeTemplate(arg.Default) {
+		if err := tpl.CheckActionTemplateParses(arg.Default); err != nil {
+			out = append(out, actionIssue(action, configissues.SeverityError, configissues.CodeTemplateParse,
+				fmt.Sprintf("Argument default template failed to parse: %v", err), arg.Default, arg.Name))
+		}
+	}
+
+	for _, choice := range arg.Choices {
+		out = append(out, templateIssueForChoice(action, arg, choice.Title, "title")...)
+		out = append(out, templateIssueForChoice(action, arg, choice.Value, "value")...)
+	}
+
+	return out
+}
+
+func templateIssueForChoice(action *config.Action, arg *config.ActionArgument, value, field string) []configissues.Issue {
+	if !looksLikeTemplate(value) {
+		return nil
+	}
+
+	if err := tpl.CheckActionTemplateParses(value); err != nil {
+		return []configissues.Issue{actionIssue(action, configissues.SeverityError, configissues.CodeTemplateParse,
+			fmt.Sprintf("Argument choice %s template failed to parse: %v", field, err), value, arg.Name)}
+	}
+
+	return nil
+}
+
+func looksLikeTemplate(value string) bool {
+	return strings.Contains(value, "{{")
+}
+
+func collectEntityFileIssues(cfg *config.Config) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+	baseDir := entities.ResolveEntitiesBaseDir(cfg.GetDir())
+
+	for _, ef := range cfg.Entities {
+		out = append(out, entityFileIssuesFor(ef, baseDir)...)
+	}
+
+	return out
+}
+
+func entityFileIssuesFor(ef *config.EntityFile, baseDir string) []configissues.Issue {
+	if ef == nil || ef.File == "" {
+		return nil
+	}
+
+	path := resolveEntityPath(ef.File, baseDir)
+	issue := entityFileIssue(ef, path)
+	if issue == nil {
+		return nil
+	}
+	return []configissues.Issue{*issue}
+}
+
+func resolveEntityPath(file, baseDir string) string {
+	if filepath.IsAbs(file) {
+		return file
+	}
+	return filepath.Join(baseDir, file)
+}
+
+func entityFileIssue(ef *config.EntityFile, path string) *configissues.Issue {
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return entityReadIssue(ef, path, err)
+	}
+
+	if len(strings.TrimSpace(string(data))) == 0 {
+		return &configissues.Issue{
+			Severity:   configissues.SeverityWarning,
+			Code:       configissues.CodeEntityFile,
+			Message:    fmt.Sprintf("Entity %q file is empty", ef.Name),
+			Source:     path,
+			ConfigFile: ef.SourceFile,
+		}
+	}
+
+	if strings.HasSuffix(path, ".json") {
+		return entityJSONParseIssue(ef, path, data)
+	}
+
+	return entityYAMLParseIssue(ef, path, data)
+}
+
+func entityReadIssue(ef *config.EntityFile, path string, err error) *configissues.Issue {
+	return &configissues.Issue{
+		Severity:   configissues.SeverityError,
+		Code:       configissues.CodeEntityFile,
+		Message:    fmt.Sprintf("Entity %q file could not be read: %v", ef.Name, err),
+		Source:     path,
+		ConfigFile: ef.SourceFile,
+	}
+}
+
+func entityJSONParseIssue(ef *config.EntityFile, path string, data []byte) *configissues.Issue {
+	decoder := json.NewDecoder(bytes.NewReader(data))
+	for decoder.More() {
+		d := make(map[string]any)
+		if err := decoder.Decode(&d); err != nil {
+			return &configissues.Issue{
+				Severity:   configissues.SeverityError,
+				Code:       configissues.CodeEntityFile,
+				Message:    fmt.Sprintf("Entity %q file could not be parsed as JSON: %v", ef.Name, err),
+				Source:     path,
+				ConfigFile: ef.SourceFile,
+			}
+		}
+	}
+	return nil
+}
+
+func entityYAMLParseIssue(ef *config.EntityFile, path string, data []byte) *configissues.Issue {
+	var parsed []map[string]any
+	if err := yaml.Unmarshal(data, &parsed); err != nil {
+		return &configissues.Issue{
+			Severity:   configissues.SeverityError,
+			Code:       configissues.CodeEntityFile,
+			Message:    fmt.Sprintf("Entity %q file could not be parsed as YAML: %v", ef.Name, err),
+			Source:     path,
+			ConfigFile: ef.SourceFile,
+		}
+	}
+	return nil
+}
+
+func collectEntityEmptyIssues(cfg *config.Config) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+	for _, action := range cfg.Actions {
+		out = append(out, entityEmptyIssueFor(cfg, action)...)
+	}
+	return out
+}
+
+func entityEmptyIssueFor(cfg *config.Config, action *config.Action) []configissues.Issue {
+	if !shouldReportEntityEmpty(cfg, action) {
+		return nil
+	}
+
+	return []configissues.Issue{actionIssue(action, configissues.SeverityWarning, configissues.CodeEntityEmpty,
+		fmt.Sprintf("Entity-bound action has no instances of entity %q", action.Entity), action.Entity, "")}
+}
+
+func shouldReportEntityEmpty(cfg *config.Config, action *config.Action) bool {
+	if action == nil || action.Entity == "" {
+		return false
+	}
+	if len(entities.GetEntityInstances(action.Entity)) > 0 {
+		return false
+	}
+	return entityEmptyLoadGatePassed(cfg, action.Entity)
+}
+
+func entityEmptyLoadGatePassed(cfg *config.Config, entityName string) bool {
+	// Avoid startup false positives: entity files load after the first action-map rebuild.
+	if entityTypeConfigured(cfg, entityName) && !entities.HasEntityLoadAttempted(entityName) {
+		return false
+	}
+	return true
+}
+
+func entityTypeConfigured(cfg *config.Config, entityName string) bool {
+	for _, ef := range cfg.Entities {
+		if ef != nil && ef.Name == entityName {
+			return true
+		}
+	}
+	return false
+}
+
+func collectCronIssues(cfg *config.Config) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+	parser := cronParser(cfg)
+
+	for _, action := range cfg.Actions {
+		out = append(out, cronIssuesForAction(action, parser)...)
+	}
+
+	return out
+}
+
+func cronIssuesForAction(action *config.Action, parser cron.Parser) []configissues.Issue {
+	if action == nil {
+		return nil
+	}
+
+	out := make([]configissues.Issue, 0)
+	out = append(out, cronEntityBindingIssue(action)...)
+	out = append(out, cronScheduleIssues(action, parser)...)
+	return out
+}
+
+func cronEntityBindingIssue(action *config.Action) []configissues.Issue {
+	if action.Entity == "" || len(action.ExecOnCron) == 0 {
+		return nil
+	}
+
+	return []configissues.Issue{actionIssue(action, configissues.SeverityWarning, configissues.CodeCronEntityBinding,
+		"Entity-bound action uses execOnCron; cron runs without an entity binding and will not execute", action.Entity, "")}
+}
+
+func cronScheduleIssues(action *config.Action, parser cron.Parser) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+	for _, cronline := range action.ExecOnCron {
+		out = append(out, cronScheduleIssue(action, parser, cronline)...)
+	}
+	return out
+}
+
+func cronScheduleIssue(action *config.Action, parser cron.Parser, cronline string) []configissues.Issue {
+	_, err := parser.Parse(cronline)
+	if err == nil {
+		return nil
+	}
+
+	return []configissues.Issue{actionIssue(action, configissues.SeverityError, configissues.CodeCronInvalid,
+		fmt.Sprintf("Invalid cron schedule %q: %v", cronline, err), cronline, "")}
+}
+
+func cronParser(cfg *config.Config) cron.Parser {
+	if cfg.CronSupportForSeconds {
+		return cron.NewParser(cron.Second | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
+	}
+	return cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor)
+}
+
+func collectWatcherPathIssues(cfg *config.Config) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+	for _, action := range cfg.Actions {
+		out = append(out, watcherPathIssuesForAction(action)...)
+	}
+	out = append(out, watcherPathIssuesForEntities(cfg)...)
+	return out
+}
+
+func watcherPathIssuesForAction(action *config.Action) []configissues.Issue {
+	if action == nil {
+		return nil
+	}
+
+	out := make([]configissues.Issue, 0)
+	for _, dir := range action.ExecOnFileCreatedInDir {
+		out = append(out, watcherDirIssue(action, dir)...)
+	}
+	for _, dir := range action.ExecOnFileChangedInDir {
+		out = append(out, watcherDirIssue(action, dir)...)
+	}
+	if action.ExecOnCalendarFile != "" {
+		out = append(out, watcherFileIssue(action, action.ExecOnCalendarFile)...)
+	}
+	return out
+}
+
+func watcherPathIssuesForEntities(cfg *config.Config) []configissues.Issue {
+	out := make([]configissues.Issue, 0)
+	baseDir := entities.ResolveEntitiesBaseDir(cfg.GetDir())
+
+	for _, ef := range cfg.Entities {
+		if ef == nil || ef.File == "" {
+			continue
+		}
+		path := resolveEntityPath(ef.File, baseDir)
+		out = append(out, missingWatchDirIssue(path, filepath.Dir(path), ef.SourceFile, "", "")...)
+	}
+	return out
+}
+
+func watcherDirIssue(action *config.Action, dir string) []configissues.Issue {
+	if dir == "" {
+		return nil
+	}
+	return missingWatchDirIssue(dir, dir, action.SourceFile, action.ID, action.Title)
+}
+
+func watcherFileIssue(action *config.Action, filePath string) []configissues.Issue {
+	if filePath == "" {
+		return nil
+	}
+	return missingWatchDirIssue(filePath, filepath.Dir(filePath), action.SourceFile, action.ID, action.Title)
+}
+
+func missingWatchDirIssue(displayPath, dirToStat, configFile, actionID, actionTitle string) []configissues.Issue {
+	_, err := os.Stat(dirToStat)
+	if err == nil {
+		return nil
+	}
+
+	return []configissues.Issue{{
+		Severity:    configissues.SeverityError,
+		Code:        configissues.CodeWatcherPath,
+		Message:     fmt.Sprintf("Could not create watcher for %q: %v", displayPath, err),
+		ActionID:    actionID,
+		ActionTitle: actionTitle,
+		Source:      displayPath,
+		ConfigFile:  configFile,
+	}}
+}

+ 201 - 0
service/internal/configcheck/rebuild_test.go

@@ -0,0 +1,201 @@
+package configcheck_test
+
+import (
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/configcheck"
+	"github.com/OliveTin/OliveTin/internal/configissues"
+	"github.com/OliveTin/OliveTin/internal/entities"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestRebuildTemplateParseDefault(t *testing.T) {
+	configissues.BeginConfigLoad()
+	cfg := config.DefaultConfig()
+	cfg.Actions = []*config.Action{
+		{
+			Title:  "Deploy",
+			ID:     "deploy",
+			Entity: "host",
+			Arguments: []config.ActionArgument{
+				{
+					Name:    "ansible_host",
+					Type:    "ascii_identifier",
+					Default: `{{ host.name | default "All" }}`,
+				},
+			},
+		},
+	}
+
+	configcheck.Rebuild(cfg)
+
+	issues := configissues.List()
+	require.NotEmpty(t, issues)
+	assert.True(t, hasCode(issues, configissues.CodeTemplateParse))
+	assert.True(t, hasCode(issues, configissues.CodeEntityEmpty))
+	assert.False(t, hasCode(issues, configissues.CodeCronEntityBinding))
+}
+
+func TestRebuildValidTemplateDefault(t *testing.T) {
+	configissues.BeginConfigLoad()
+	cfg := config.DefaultConfig()
+	cfg.Actions = []*config.Action{
+		{
+			Title: "Sleep",
+			ID:    "sleep",
+			Arguments: []config.ActionArgument{
+				{
+					Name:    "ssh_host",
+					Type:    "ascii_identifier",
+					Default: `{{ host.hostname }}`,
+				},
+			},
+		},
+	}
+
+	configcheck.Rebuild(cfg)
+
+	assert.False(t, hasCode(configissues.List(), configissues.CodeTemplateParse))
+}
+
+func TestRebuildCronEntityBinding(t *testing.T) {
+	configissues.BeginConfigLoad()
+	cfg := config.DefaultConfig()
+	cfg.Actions = []*config.Action{
+		{
+			Title:      "Nightly",
+			ID:         "nightly",
+			Entity:     "host",
+			ExecOnCron: []string{"0 1 * * *"},
+		},
+	}
+
+	configcheck.Rebuild(cfg)
+
+	assert.True(t, hasCode(configissues.List(), configissues.CodeCronEntityBinding))
+}
+
+func TestRebuildInvalidCron(t *testing.T) {
+	configissues.BeginConfigLoad()
+	cfg := config.DefaultConfig()
+	cfg.Actions = []*config.Action{
+		{
+			Title:      "Broken cron",
+			ID:         "broken",
+			ExecOnCron: []string{"not a cron line"},
+		},
+	}
+
+	configcheck.Rebuild(cfg)
+
+	assert.True(t, hasCode(configissues.List(), configissues.CodeCronInvalid))
+}
+
+func TestRebuildMissingEntityFile(t *testing.T) {
+	configissues.BeginConfigLoad()
+	cfg := config.DefaultConfig()
+	dir := t.TempDir()
+	cfg.SetDir(dir)
+	cfg.Entities = []*config.EntityFile{
+		{Name: "host", File: "missing-hosts.yaml"},
+	}
+
+	configcheck.Rebuild(cfg)
+
+	assert.True(t, hasCode(configissues.List(), configissues.CodeEntityFile))
+}
+
+func TestRebuildEntityFileLoadsAndClearsEmpty(t *testing.T) {
+	configissues.BeginConfigLoad()
+	cfg := config.DefaultConfig()
+	dir := t.TempDir()
+	cfg.SetDir(dir)
+
+	entityPath := filepath.Join(dir, "hosts.yaml")
+	require.NoError(t, os.WriteFile(entityPath, []byte("- name: cradle\n"), 0o644))
+
+	cfg.Entities = []*config.EntityFile{
+		{Name: "host", File: "hosts.yaml"},
+	}
+	cfg.Actions = []*config.Action{
+		{Title: "Wake", ID: "wake", Entity: "host"},
+	}
+
+	entities.AddEntity("host", "0", map[string]any{"name": "cradle"})
+	t.Cleanup(func() { entities.ClearEntitiesOfType("host") })
+
+	configcheck.Rebuild(cfg)
+
+	issues := configissues.List()
+	assert.False(t, hasCode(issues, configissues.CodeEntityFile))
+	assert.False(t, hasCode(issues, configissues.CodeEntityEmpty))
+}
+
+func TestRebuildIncludesActionConfigFile(t *testing.T) {
+	configissues.BeginConfigLoad()
+	cfg := config.DefaultConfig()
+	cfg.Actions = []*config.Action{
+		{
+			Title:      "Nightly",
+			ID:         "nightly",
+			Entity:     "host",
+			SourceFile: "/etc/OliveTin/config.d/16-gitops-sr-soe.yaml",
+			ExecOnCron: []string{"0 1 * * *"},
+		},
+	}
+
+	configcheck.Rebuild(cfg)
+
+	var found bool
+	for _, issue := range configissues.List() {
+		if issue.Code == configissues.CodeCronEntityBinding {
+			assert.Equal(t, "/etc/OliveTin/config.d/16-gitops-sr-soe.yaml", issue.ConfigFile)
+			found = true
+		}
+	}
+	assert.True(t, found)
+}
+
+func TestRebuildSkipsEntityEmptyBeforeLoadAttempt(t *testing.T) {
+	entities.ResetEntityLoadAttempts()
+	configissues.BeginConfigLoad()
+	cfg := config.DefaultConfig()
+	cfg.Entities = []*config.EntityFile{{Name: "container", File: "containers.json"}}
+	cfg.Actions = []*config.Action{
+		{Title: "Start container", ID: "start", Entity: "container"},
+	}
+
+	configcheck.Rebuild(cfg)
+	assert.False(t, hasCode(configissues.List(), configissues.CodeEntityEmpty))
+
+	entities.MarkEntityLoadAttempted("container")
+	configcheck.Rebuild(cfg)
+	assert.True(t, hasCode(configissues.List(), configissues.CodeEntityEmpty))
+}
+
+func TestStickyEnvUnsetSurvivesRebuild(t *testing.T) {
+	configissues.BeginConfigLoad()
+	configissues.ReportSticky(configissues.Issue{
+		Severity: configissues.SeverityWarning,
+		Code:     configissues.CodeEnvUnset,
+		Message:  "unset",
+		Source:   "MISSING_VAR",
+	})
+
+	configcheck.Rebuild(config.DefaultConfig())
+
+	assert.True(t, hasCode(configissues.List(), configissues.CodeEnvUnset))
+}
+
+func hasCode(issues []configissues.Issue, code string) bool {
+	for _, issue := range issues {
+		if issue.Code == code {
+			return true
+		}
+	}
+	return false
+}

+ 46 - 0
service/internal/configcheck/watcher_path_test.go

@@ -0,0 +1,46 @@
+package configcheck_test
+
+import (
+	"path/filepath"
+	"testing"
+
+	"github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/configcheck"
+	"github.com/OliveTin/OliveTin/internal/configissues"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestRebuildWatcherPathMissingDir(t *testing.T) {
+	configissues.BeginConfigLoad()
+	configissues.Replace(nil)
+
+	missing := filepath.Join(t.TempDir(), "does-not-exist")
+	cfg := config.DefaultConfig()
+	cfg.Actions = []*config.Action{
+		{
+			Title:                  "On create",
+			ID:                     "on_create",
+			SourceFile:             "/etc/OliveTin/config.yaml",
+			ExecOnFileCreatedInDir: []string{missing},
+		},
+	}
+
+	configcheck.Rebuild(cfg)
+
+	issues := configissues.List()
+	require.True(t, hasCode(issues, configissues.CodeWatcherPath))
+
+	var found *configissues.Issue
+	for i := range issues {
+		if issues[i].Code == configissues.CodeWatcherPath {
+			found = &issues[i]
+			break
+		}
+	}
+	require.NotNil(t, found)
+	assert.Equal(t, missing, found.Source)
+	assert.Equal(t, "/etc/OliveTin/config.yaml", found.ConfigFile)
+	assert.Contains(t, found.Message, missing)
+	assert.Contains(t, found.Message, "Could not create watcher")
+}

+ 33 - 0
service/internal/configissues/issue.go

@@ -0,0 +1,33 @@
+package configissues
+
+const (
+	SeverityWarning = "warning"
+	SeverityError   = "error"
+
+	CodeActionGroupUnknown     = "action_group_unknown"
+	CodeActionGroupUnenforced  = "action_group_unenforced"
+	CodeArgTypeUnset           = "arg_type_unset"
+	CodeChecklistNoChoices     = "checklist_no_choices"
+	CodeChecklistEntityChoices = "checklist_entity_choices"
+	CodeEnvUnset               = "env_unset"
+	CodeIncludeMissing         = "include_missing"
+	CodeTemplateParse          = "template_parse"
+	CodeArgDefaultInvalid      = "arg_default_invalid"
+	CodeEntityFile             = "entity_file"
+	CodeEntityEmpty            = "entity_empty"
+	CodeCronInvalid            = "cron_invalid"
+	CodeCronEntityBinding      = "cron_entity_binding"
+	CodeWatcherPath            = "watcher_path"
+)
+
+// Issue is a configuration warning or error surfaced on Diagnostics.
+type Issue struct {
+	Severity     string
+	Code         string
+	Message      string
+	ActionID     string
+	ActionTitle  string
+	ArgumentName string
+	Source       string
+	ConfigFile   string
+}

+ 125 - 0
service/internal/configissues/store.go

@@ -0,0 +1,125 @@
+package configissues
+
+import (
+	"sync"
+
+	log "github.com/sirupsen/logrus"
+)
+
+var (
+	storeMu sync.RWMutex
+	issues  []Issue
+
+	stickyMu sync.Mutex
+	sticky   []Issue
+)
+
+// BeginConfigLoad clears sticky issues captured during config decode/include load.
+func BeginConfigLoad() {
+	stickyMu.Lock()
+	defer stickyMu.Unlock()
+	sticky = nil
+}
+
+// ReportSticky records an issue that cannot be re-derived after config decode
+// (for example unset environment variables that were already expanded).
+func ReportSticky(issue Issue) {
+	stickyMu.Lock()
+	defer stickyMu.Unlock()
+	sticky = append(sticky, issue)
+}
+
+func copySticky() []Issue {
+	stickyMu.Lock()
+	defer stickyMu.Unlock()
+	out := make([]Issue, len(sticky))
+	copy(out, sticky)
+	return out
+}
+
+// CopySticky returns a copy of sticky load-time issues.
+func CopySticky() []Issue {
+	return copySticky()
+}
+
+// Replace sets the current issue list and logs only newly appeared issues.
+func Replace(next []Issue) {
+	storeMu.Lock()
+	defer storeMu.Unlock()
+
+	previous := make(map[string]struct{}, len(issues))
+	for _, issue := range issues {
+		previous[issueFingerprint(issue)] = struct{}{}
+	}
+
+	issues = make([]Issue, len(next))
+	copy(issues, next)
+
+	for _, issue := range issues {
+		if _, exists := previous[issueFingerprint(issue)]; exists {
+			continue
+		}
+		logNewIssue(issue)
+	}
+}
+
+func issueFingerprint(issue Issue) string {
+	return issue.Severity + "\x00" +
+		issue.Code + "\x00" +
+		issue.ActionID + "\x00" +
+		issue.ActionTitle + "\x00" +
+		issue.ArgumentName + "\x00" +
+		issue.Source + "\x00" +
+		issue.ConfigFile + "\x00" +
+		issue.Message
+}
+
+func logNewIssue(issue Issue) {
+	fields := log.Fields{
+		"code":         issue.Code,
+		"actionTitle":  issue.ActionTitle,
+		"argumentName": issue.ArgumentName,
+		"configFile":   issue.ConfigFile,
+		"source":       issue.Source,
+	}
+
+	if issue.Severity == SeverityError {
+		log.WithFields(fields).Error(issue.Message)
+		return
+	}
+	log.WithFields(fields).Warn(issue.Message)
+}
+
+// Report adds a live issue if it is not already present and logs it when new.
+// Use this for runtime discoveries that happen outside Rebuild (for example
+// filesystem watcher setup failures).
+func Report(issue Issue) {
+	storeMu.Lock()
+	defer storeMu.Unlock()
+
+	fp := issueFingerprint(issue)
+	for _, existing := range issues {
+		if issueFingerprint(existing) == fp {
+			return
+		}
+	}
+
+	issues = append(issues, issue)
+	logNewIssue(issue)
+}
+
+// List returns a copy of the current issues.
+func List() []Issue {
+	storeMu.RLock()
+	defer storeMu.RUnlock()
+	out := make([]Issue, len(issues))
+	copy(out, issues)
+	return out
+}
+
+// Count returns the number of current issues.
+func Count() int {
+	storeMu.RLock()
+	defer storeMu.RUnlock()
+	return len(issues)
+}

+ 39 - 0
service/internal/configissues/store_test.go

@@ -0,0 +1,39 @@
+package configissues_test
+
+import (
+	"testing"
+
+	"github.com/OliveTin/OliveTin/internal/configissues"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestStoreReplaceListCount(t *testing.T) {
+	configissues.BeginConfigLoad()
+	configissues.Replace([]configissues.Issue{{
+		Severity: configissues.SeverityError,
+		Code:     configissues.CodeTemplateParse,
+		Message:  "broken",
+	}})
+
+	assert.Equal(t, 1, configissues.Count())
+	assert.Equal(t, configissues.CodeTemplateParse, configissues.List()[0].Code)
+
+	configissues.Replace(nil)
+	assert.Equal(t, 0, configissues.Count())
+}
+
+func TestStickyCopy(t *testing.T) {
+	configissues.BeginConfigLoad()
+	configissues.ReportSticky(configissues.Issue{
+		Code:    configissues.CodeEnvUnset,
+		Message: "missing",
+		Source:  "FOO",
+	})
+
+	copied := configissues.CopySticky()
+	assert.Len(t, copied, 1)
+	assert.Equal(t, configissues.CodeEnvUnset, copied[0].Code)
+
+	configissues.BeginConfigLoad()
+	assert.Empty(t, configissues.CopySticky())
+}

+ 27 - 11
service/internal/entities/entities.go

@@ -30,13 +30,18 @@ func AddListener(l func()) {
 }
 }
 
 
 func SetupEntityFileWatchers(cfg *config.Config) {
 func SetupEntityFileWatchers(cfg *config.Config) {
-	baseDir := resolveEntitiesBaseDir(cfg.GetDir())
+	baseDir := ResolveEntitiesBaseDir(cfg.GetDir())
 	for i := range cfg.Entities { // #337 - iterate by key, not by value
 	for i := range cfg.Entities { // #337 - iterate by key, not by value
 		ef := cfg.Entities[i]
 		ef := cfg.Entities[i]
 		watchAndLoadEntity(baseDir, ef)
 		watchAndLoadEntity(baseDir, ef)
 	}
 	}
 }
 }
 
 
+// ResolveEntitiesBaseDir returns the directory used to resolve relative entity file paths.
+func ResolveEntitiesBaseDir(configDir string) string {
+	return resolveEntitiesBaseDir(configDir)
+}
+
 //gocyclo:ignore
 //gocyclo:ignore
 func resolveEntitiesBaseDir(configDir string) string {
 func resolveEntitiesBaseDir(configDir string) string {
 	absConfigDir, err := filepath.Abs(configDir)
 	absConfigDir, err := filepath.Abs(configDir)
@@ -64,15 +69,26 @@ func watchAndLoadEntity(baseDir string, ef *config.EntityFile) {
 		p = filepath.Join(baseDir, p)
 		p = filepath.Join(baseDir, p)
 		log.WithFields(log.Fields{"entityFile": p}).Debugf("Adding config dir to entity file path")
 		log.WithFields(log.Fields{"entityFile": p}).Debugf("Adding config dir to entity file path")
 	}
 	}
-	go filehelper.WatchFileWrite(p, func(filename string) { loadEntityFile(p, ef.Name) })
+	go filehelper.WatchFileWrite(p, func(filename string) { loadEntityFile(p, ef.Name) }, filehelper.WatchMeta{})
 	loadEntityFile(p, ef.Name)
 	loadEntityFile(p, ef.Name)
 }
 }
 
 
 func loadEntityFile(filename string, entityname string) {
 func loadEntityFile(filename string, entityname string) {
+	defer func() {
+		MarkEntityLoadAttempted(entityname)
+		notifyEntityListeners()
+	}()
+
 	if strings.HasSuffix(filename, ".json") {
 	if strings.HasSuffix(filename, ".json") {
 		loadEntityFileJson(filename, entityname)
 		loadEntityFileJson(filename, entityname)
-	} else {
-		loadEntityFileYaml(filename, entityname)
+		return
+	}
+	loadEntityFileYaml(filename, entityname)
+}
+
+func notifyEntityListeners() {
+	for _, l := range listeners {
+		l()
 	}
 	}
 }
 }
 
 
@@ -86,6 +102,7 @@ func loadEntityFileJson(filename string, entityname string) {
 
 
 	if err != nil {
 	if err != nil {
 		log.Errorf("ReadIn: %v", err)
 		log.Errorf("ReadIn: %v", err)
+		ClearEntitiesOfType(entityname)
 		return
 		return
 	}
 	}
 
 
@@ -100,13 +117,14 @@ func loadEntityFileJson(filename string, entityname string) {
 
 
 		if err != nil {
 		if err != nil {
 			log.Errorf("%v", err)
 			log.Errorf("%v", err)
+			ClearEntitiesOfType(entityname)
 			return
 			return
 		}
 		}
 
 
 		data = append(data, d)
 		data = append(data, d)
 	}
 	}
 
 
-	updateSvFromFile(entityname, data)
+	replaceEntitiesFromFile(entityname, data)
 }
 }
 
 
 func loadEntityFileYaml(filename string, entityname string) {
 func loadEntityFileYaml(filename string, entityname string) {
@@ -119,6 +137,7 @@ func loadEntityFileYaml(filename string, entityname string) {
 
 
 	if err != nil {
 	if err != nil {
 		log.Errorf("ReadIn: %v", err)
 		log.Errorf("ReadIn: %v", err)
+		ClearEntitiesOfType(entityname)
 		return
 		return
 	}
 	}
 
 
@@ -128,22 +147,19 @@ func loadEntityFileYaml(filename string, entityname string) {
 
 
 	if err != nil {
 	if err != nil {
 		log.Errorf("Unmarshal: %v", err)
 		log.Errorf("Unmarshal: %v", err)
+		ClearEntitiesOfType(entityname)
 		return
 		return
 	}
 	}
 
 
-	updateSvFromFile(entityname, data)
+	replaceEntitiesFromFile(entityname, data)
 }
 }
 
 
-func updateSvFromFile(entityname string, data []map[string]any) {
+func replaceEntitiesFromFile(entityname string, data []map[string]any) {
 	ClearEntitiesOfType(entityname)
 	ClearEntitiesOfType(entityname)
 
 
 	for i, mapp := range data {
 	for i, mapp := range data {
 		AddEntity(entityname, fmt.Sprintf("%d", i), mapp)
 		AddEntity(entityname, fmt.Sprintf("%d", i), mapp)
 	}
 	}
-
-	for _, l := range listeners {
-		l()
-	}
 }
 }
 
 
 /*
 /*

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

@@ -0,0 +1,33 @@
+package entities
+
+import "sync"
+
+var (
+	loadAttemptedMu sync.RWMutex
+	loadAttempted   = map[string]bool{}
+)
+
+// MarkEntityLoadAttempted records that OliveTin has tried to load this entity
+// type from its entity file (successfully or not).
+func MarkEntityLoadAttempted(entityName string) {
+	if entityName == "" {
+		return
+	}
+	loadAttemptedMu.Lock()
+	defer loadAttemptedMu.Unlock()
+	loadAttempted[entityName] = true
+}
+
+// HasEntityLoadAttempted reports whether a load has been attempted for the entity type.
+func HasEntityLoadAttempted(entityName string) bool {
+	loadAttemptedMu.RLock()
+	defer loadAttemptedMu.RUnlock()
+	return loadAttempted[entityName]
+}
+
+// ResetEntityLoadAttempts clears load-attempt tracking (used by tests).
+func ResetEntityLoadAttempts() {
+	loadAttemptedMu.Lock()
+	defer loadAttemptedMu.Unlock()
+	loadAttempted = map[string]bool{}
+}

+ 45 - 0
service/internal/executor/argument_defaults_test.go

@@ -0,0 +1,45 @@
+package executor
+
+import (
+	"testing"
+
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/configissues"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestValidateArgumentDefaultSkipsTemplates(t *testing.T) {
+	action := &config.Action{Title: "Deploy", ID: "deploy"}
+	arg := &config.ActionArgument{
+		Name:    "host",
+		Type:    "ascii_identifier",
+		Default: `{{ host.name }}`,
+	}
+
+	assert.Nil(t, validateArgumentDefault(action, arg))
+}
+
+func TestValidateArgumentDefaultLiteralInvalid(t *testing.T) {
+	action := &config.Action{Title: "Deploy", ID: "deploy"}
+	arg := &config.ActionArgument{
+		Name:    "host",
+		Type:    "ascii_identifier",
+		Default: "not a valid id!!",
+	}
+
+	issue := validateArgumentDefault(action, arg)
+	require.NotNil(t, issue)
+	assert.Equal(t, configissues.CodeArgDefaultInvalid, issue.Code)
+}
+
+func TestValidateArgumentDefaultLiteralValid(t *testing.T) {
+	action := &config.Action{Title: "Deploy", ID: "deploy"}
+	arg := &config.ActionArgument{
+		Name:    "host",
+		Type:    "ascii_identifier",
+		Default: "example.com",
+	}
+
+	assert.Nil(t, validateArgumentDefault(action, arg))
+}

+ 35 - 17
service/internal/executor/executor_actions.go

@@ -3,10 +3,12 @@ package executor
 import (
 import (
 	"crypto/sha256"
 	"crypto/sha256"
 	"fmt"
 	"fmt"
+	"strings"
 
 
-	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/config"
+	"github.com/OliveTin/OliveTin/internal/configcheck"
+	"github.com/OliveTin/OliveTin/internal/configissues"
 	"github.com/OliveTin/OliveTin/internal/entities"
 	"github.com/OliveTin/OliveTin/internal/entities"
-	log "github.com/sirupsen/logrus"
 )
 )
 
 
 func (e *Executor) FindBindingByID(id string) *ActionBinding {
 func (e *Executor) FindBindingByID(id string) *ActionBinding {
@@ -40,40 +42,54 @@ type RebuildActionMapRequest struct {
 	dashboardTargets *dashboardTargetIndex
 	dashboardTargets *dashboardTargetIndex
 }
 }
 
 
-func validateArgumentDefaults(cfg *config.Config) {
+func validateArgumentDefaults(cfg *config.Config) []configissues.Issue {
 	if cfg == nil {
 	if cfg == nil {
-		return
+		return nil
 	}
 	}
+	out := make([]configissues.Issue, 0)
 	for _, action := range cfg.Actions {
 	for _, action := range cfg.Actions {
-		validateActionArgumentDefaults(action)
+		out = append(out, validateActionArgumentDefaults(action)...)
 	}
 	}
+	return out
 }
 }
 
 
-func validateActionArgumentDefaults(action *config.Action) {
+func validateActionArgumentDefaults(action *config.Action) []configissues.Issue {
 	if action == nil {
 	if action == nil {
-		return
+		return nil
 	}
 	}
+	out := make([]configissues.Issue, 0)
 	for i := range action.Arguments {
 	for i := range action.Arguments {
-		validateArgumentDefault(action, &action.Arguments[i])
+		if issue := validateArgumentDefault(action, &action.Arguments[i]); issue != nil {
+			out = append(out, *issue)
+		}
 	}
 	}
+	return out
 }
 }
 
 
-func validateArgumentDefault(action *config.Action, arg *config.ActionArgument) {
+func validateArgumentDefault(action *config.Action, arg *config.ActionArgument) *configissues.Issue {
 	if arg.Default == "" {
 	if arg.Default == "" {
-		return
+		return nil
+	}
+	if strings.Contains(arg.Default, "{{") {
+		return nil
 	}
 	}
 	if err := ValidateArgument(arg, arg.Default, action); err != nil {
 	if err := ValidateArgument(arg, arg.Default, action); err != nil {
-		log.WithFields(log.Fields{
-			"actionTitle": action.Title,
-			"argName":     arg.Name,
-			"default":     arg.Default,
-			"error":       err,
-		}).Warn("Argument default value failed validation")
+		return &configissues.Issue{
+			Severity:     configissues.SeverityWarning,
+			Code:         configissues.CodeArgDefaultInvalid,
+			Message:      fmt.Sprintf("Argument default value failed validation: %v", err),
+			ActionID:     action.ID,
+			ActionTitle:  action.Title,
+			ArgumentName: arg.Name,
+			Source:       arg.Default,
+			ConfigFile:   action.SourceFile,
+		}
 	}
 	}
+	return nil
 }
 }
 
 
 func (e *Executor) RebuildActionMap() {
 func (e *Executor) RebuildActionMap() {
-	validateArgumentDefaults(e.Cfg)
+	defaultIssues := validateArgumentDefaults(e.Cfg)
 
 
 	e.MapActionBindingsLock.Lock()
 	e.MapActionBindingsLock.Lock()
 
 
@@ -94,6 +110,8 @@ func (e *Executor) RebuildActionMap() {
 
 
 	e.MapActionBindingsLock.Unlock()
 	e.MapActionBindingsLock.Unlock()
 
 
+	configcheck.Rebuild(e.Cfg, defaultIssues...)
+
 	for _, l := range e.copyListeners() {
 	for _, l := range e.copyListeners() {
 		l.OnActionMapRebuilt()
 		l.OnActionMapRebuilt()
 	}
 	}

+ 75 - 37
service/internal/filehelper/file_change_notify.go

@@ -1,12 +1,14 @@
 package filehelper
 package filehelper
 
 
 import (
 import (
-	"github.com/fsnotify/fsnotify"
-	log "github.com/sirupsen/logrus"
+	"fmt"
 	"path/filepath"
 	"path/filepath"
+	"sync"
 	"time"
 	"time"
 
 
-	"sync"
+	"github.com/OliveTin/OliveTin/internal/configissues"
+	"github.com/fsnotify/fsnotify"
+	log "github.com/sirupsen/logrus"
 )
 )
 
 
 var (
 var (
@@ -28,33 +30,44 @@ const (
 	debounceDelay = 300 * time.Millisecond
 	debounceDelay = 300 * time.Millisecond
 )
 )
 
 
+// WatchMeta attaches action context to watcher setup failures so Diagnostics
+// can apply the same action view ACL as other configuration issues.
+type WatchMeta struct {
+	ActionID    string
+	ActionTitle string
+	ConfigFile  string
+}
+
 type watchContext struct {
 type watchContext struct {
 	filename        string
 	filename        string
 	filedir         string
 	filedir         string
 	callback        func(filename string)
 	callback        func(filename string)
 	interestedEvent fsnotify.Op
 	interestedEvent fsnotify.Op
 	event           *fsnotify.Event
 	event           *fsnotify.Event
+	meta            WatchMeta
 }
 }
 
 
-func WatchDirectoryCreate(fullpath string, callback func(filename string)) {
+func WatchDirectoryCreate(fullpath string, callback func(filename string), meta WatchMeta) {
 	watchPath(&watchContext{
 	watchPath(&watchContext{
 		filedir:         fullpath,
 		filedir:         fullpath,
 		filename:        "",
 		filename:        "",
 		callback:        callback,
 		callback:        callback,
 		interestedEvent: fsnotify.Create,
 		interestedEvent: fsnotify.Create,
+		meta:            meta,
 	})
 	})
 }
 }
 
 
-func WatchDirectoryWrite(fullpath string, callback func(filename string)) {
+func WatchDirectoryWrite(fullpath string, callback func(filename string), meta WatchMeta) {
 	watchPath(&watchContext{
 	watchPath(&watchContext{
 		filedir:         fullpath,
 		filedir:         fullpath,
 		filename:        "",
 		filename:        "",
 		callback:        callback,
 		callback:        callback,
 		interestedEvent: fsnotify.Write,
 		interestedEvent: fsnotify.Write,
+		meta:            meta,
 	})
 	})
 }
 }
 
 
-func WatchFileWrite(fullpath string, callback func(filename string)) {
+func WatchFileWrite(fullpath string, callback func(filename string), meta WatchMeta) {
 	filename := filepath.Base(fullpath)
 	filename := filepath.Base(fullpath)
 	filedir := filepath.Dir(fullpath)
 	filedir := filepath.Dir(fullpath)
 
 
@@ -63,69 +76,94 @@ func WatchFileWrite(fullpath string, callback func(filename string)) {
 		filename:        filename,
 		filename:        filename,
 		callback:        callback,
 		callback:        callback,
 		interestedEvent: fsnotify.Write,
 		interestedEvent: fsnotify.Write,
+		meta:            meta,
 	})
 	})
 }
 }
 
 
 func watchPath(ctx *watchContext) {
 func watchPath(ctx *watchContext) {
 	watcher, err := fsnotify.NewWatcher()
 	watcher, err := fsnotify.NewWatcher()
-
 	if err != nil {
 	if err != nil {
-		log.Errorf("Could not watch for files being created: %v", err)
+		reportWatcherFailure(ctx, err)
 		return
 		return
 	}
 	}
 
 
-	defer func() {
-		if err := watcher.Close(); err != nil {
-			log.Errorf("Failed to close file watcher: %v", err)
-		}
-	}()
+	defer closeWatcher(watcher)
 
 
-	done := make(chan bool)
+	if err := watcher.Add(ctx.filedir); err != nil {
+		reportWatcherFailure(ctx, err)
+		return
+	}
 
 
-	go func() {
-		for {
-			processEvent(ctx, watcher)
-		}
-	}()
+	for processEvent(ctx, watcher) {
+	}
+}
 
 
-	err = watcher.Add(ctx.filedir)
+func closeWatcher(watcher *fsnotify.Watcher) {
+	if err := watcher.Close(); err != nil {
+		log.Errorf("Failed to close file watcher: %v", err)
+	}
+}
 
 
-	if err != nil {
-		log.Errorf("Could not create watcher: %v", err)
+func watchTarget(ctx *watchContext) string {
+	if ctx.filename == "" {
+		return ctx.filedir
 	}
 	}
+	return filepath.Join(ctx.filedir, ctx.filename)
+}
 
 
-	<-done
+func reportWatcherFailure(ctx *watchContext, err error) {
+	path := watchTarget(ctx)
+	message := fmt.Sprintf("Could not create watcher for %q: %v", path, err)
+	configissues.Report(configissues.Issue{
+		Severity:    configissues.SeverityError,
+		Code:        configissues.CodeWatcherPath,
+		Message:     message,
+		ActionID:    ctx.meta.ActionID,
+		ActionTitle: ctx.meta.ActionTitle,
+		Source:      path,
+		ConfigFile:  ctx.meta.ConfigFile,
+	})
 }
 }
 
 
-func processEvent(ctx *watchContext, watcher *fsnotify.Watcher) {
+// processEvent waits for one watcher event. It returns false when the watcher
+// channels are closed so the caller can stop looping.
+func processEvent(ctx *watchContext, watcher *fsnotify.Watcher) bool {
 	select {
 	select {
 	case event, ok := <-watcher.Events:
 	case event, ok := <-watcher.Events:
-		ctx.event = &event
-
-		if !consumeEvent(ok, ctx) {
-			return
-		}
+		return handleWatcherEvent(ctx, event, ok)
+	case err, ok := <-watcher.Errors:
+		return handleWatcherError(ctx, err, ok)
+	}
+}
 
 
-		break
-	case err := <-watcher.Errors:
-		log.Errorf("Error in fsnotify: %v", err)
-		return
+func handleWatcherEvent(ctx *watchContext, event fsnotify.Event, ok bool) bool {
+	if !ok {
+		return false
 	}
 	}
+	ctx.event = &event
+	consumeEvent(ctx)
+	return true
 }
 }
 
 
-func consumeEvent(ok bool, ctx *watchContext) bool {
+func handleWatcherError(ctx *watchContext, err error, ok bool) bool {
 	if !ok {
 	if !ok {
 		return false
 		return false
 	}
 	}
+	if err != nil {
+		log.WithFields(log.Fields{
+			"path": watchTarget(ctx),
+		}).Errorf("Error in fsnotify: %v", err)
+	}
+	return true
+}
 
 
+func consumeEvent(ctx *watchContext) {
 	if ctx.filename != "" && filepath.Base(ctx.event.Name) != ctx.filename {
 	if ctx.filename != "" && filepath.Base(ctx.event.Name) != ctx.filename {
 		log.Tracef("fsnotify irreleventa event different file %+v", ctx.event)
 		log.Tracef("fsnotify irreleventa event different file %+v", ctx.event)
-		return true
+		return
 	}
 	}
 
 
 	consumeRelevantEvents(ctx)
 	consumeRelevantEvents(ctx)
-
-	return true
 }
 }
 
 
 func consumeRelevantEvents(ctx *watchContext) {
 func consumeRelevantEvents(ctx *watchContext) {

+ 63 - 0
service/internal/filehelper/watcher_path_test.go

@@ -0,0 +1,63 @@
+package filehelper_test
+
+import (
+	"path/filepath"
+	"testing"
+	"time"
+
+	"github.com/OliveTin/OliveTin/internal/configissues"
+	"github.com/OliveTin/OliveTin/internal/filehelper"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestWatchDirectoryCreateReportsMissingPath(t *testing.T) {
+	configissues.Replace(nil)
+
+	missing := filepath.Join(t.TempDir(), "missing-watch-dir")
+	done := make(chan struct{})
+	go func() {
+		filehelper.WatchDirectoryCreate(missing, func(string) {}, filehelper.WatchMeta{})
+		close(done)
+	}()
+
+	select {
+	case <-done:
+	case <-time.After(2 * time.Second):
+		t.Fatal("WatchDirectoryCreate did not return after watcher failure")
+	}
+
+	issues := configissues.List()
+	require.Len(t, issues, 1)
+	assert.Equal(t, configissues.CodeWatcherPath, issues[0].Code)
+	assert.Equal(t, missing, issues[0].Source)
+	assert.Contains(t, issues[0].Message, missing)
+	assert.Empty(t, issues[0].ActionID)
+}
+
+func TestWatchDirectoryCreateReportsActionMeta(t *testing.T) {
+	configissues.Replace(nil)
+
+	missing := filepath.Join(t.TempDir(), "missing-secret-watch")
+	done := make(chan struct{})
+	go func() {
+		filehelper.WatchDirectoryCreate(missing, func(string) {}, filehelper.WatchMeta{
+			ActionID:    "secret_action",
+			ActionTitle: "Secret Action",
+			ConfigFile:  "/etc/OliveTin/secret.yaml",
+		})
+		close(done)
+	}()
+
+	select {
+	case <-done:
+	case <-time.After(2 * time.Second):
+		t.Fatal("WatchDirectoryCreate did not return after watcher failure")
+	}
+
+	issues := configissues.List()
+	require.Len(t, issues, 1)
+	assert.Equal(t, "secret_action", issues[0].ActionID)
+	assert.Equal(t, "Secret Action", issues[0].ActionTitle)
+	assert.Equal(t, "/etc/OliveTin/secret.yaml", issues[0].ConfigFile)
+}

+ 5 - 1
service/internal/oncalendarfile/calendar.go

@@ -37,7 +37,11 @@ func Schedule(cfg *config.Config, ex *executor.Executor) {
 				parseCalendarFile(captured, cfg, ex, filename)
 				parseCalendarFile(captured, cfg, ex, filename)
 			}
 			}
 
 
-			go filehelper.WatchFileWrite(action.ExecOnCalendarFile, x)
+			go filehelper.WatchFileWrite(action.ExecOnCalendarFile, x, filehelper.WatchMeta{
+				ActionID:    action.ID,
+				ActionTitle: action.Title,
+				ConfigFile:  action.SourceFile,
+			})
 
 
 			x(action.ExecOnCalendarFile)
 			x(action.ExecOnCalendarFile)
 		}
 		}

+ 10 - 2
service/internal/onfileindir/fileindir.go

@@ -17,7 +17,7 @@ func WatchFilesInDirectory(cfg *config.Config, ex *executor.Executor) {
 			go func(act *config.Action, dir string) {
 			go func(act *config.Action, dir string) {
 				filehelper.WatchDirectoryCreate(dir, func(filename string) {
 				filehelper.WatchDirectoryCreate(dir, func(filename string) {
 					scheduleExec(act, cfg, ex, filename)
 					scheduleExec(act, cfg, ex, filename)
-				})
+				}, watchMetaForAction(act))
 			}(action, dirname)
 			}(action, dirname)
 		}
 		}
 		for _, dirname := range action.ExecOnFileChangedInDir {
 		for _, dirname := range action.ExecOnFileChangedInDir {
@@ -27,12 +27,20 @@ func WatchFilesInDirectory(cfg *config.Config, ex *executor.Executor) {
 			go func(act *config.Action, dir string) {
 			go func(act *config.Action, dir string) {
 				filehelper.WatchDirectoryWrite(dir, func(filename string) {
 				filehelper.WatchDirectoryWrite(dir, func(filename string) {
 					scheduleExec(act, cfg, ex, filename)
 					scheduleExec(act, cfg, ex, filename)
-				})
+				}, watchMetaForAction(act))
 			}(action, dirname)
 			}(action, dirname)
 		}
 		}
 	}
 	}
 }
 }
 
 
+func watchMetaForAction(action *config.Action) filehelper.WatchMeta {
+	return filehelper.WatchMeta{
+		ActionID:    action.ID,
+		ActionTitle: action.Title,
+		ConfigFile:  action.SourceFile,
+	}
+}
+
 func scheduleExec(action *config.Action, cfg *config.Config, ex *executor.Executor, path string) {
 func scheduleExec(action *config.Action, cfg *config.Config, ex *executor.Executor, path string) {
 	args := map[string]string{
 	args := map[string]string{
 		"filepath": path,
 		"filepath": path,

+ 17 - 0
service/internal/tpl/check_template_test.go

@@ -0,0 +1,17 @@
+package tpl_test
+
+import (
+	"testing"
+
+	"github.com/OliveTin/OliveTin/internal/tpl"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCheckActionTemplateParses(t *testing.T) {
+	assert.NoError(t, tpl.CheckActionTemplateParses(`{{ host.name }}`))
+	assert.NoError(t, tpl.CheckActionTemplateParses(`{{ host.hostname }}`))
+
+	err := tpl.CheckActionTemplateParses(`{{ host.name | default "All" }}`)
+	require.Error(t, err)
+}

+ 19 - 0
service/internal/tpl/templates.go

@@ -219,6 +219,25 @@ func ParseTemplateOfActionBeforeExec(source string, ent *entities.Entity) string
 	return result
 	return result
 }
 }
 
 
+// CheckActionTemplateParses migrates legacy template syntax and reports parse
+// errors only (execution against entity/argument data is not attempted).
+func CheckActionTemplateParses(source string) error {
+	if source == "" {
+		return nil
+	}
+
+	source = migrateLegacyArgumentNames(source)
+	source = migrateLegacyEntityProperties(source)
+
+	clone, err := tpl.Clone()
+	if err != nil {
+		return err
+	}
+
+	_, err = clone.Parse(source)
+	return err
+}
+
 /*
 /*
 func ParseTemplateBoolWith(source string, ent *entities.Entity) bool {
 func ParseTemplateBoolWith(source string, ent *entities.Entity) bool {
 	source = strings.TrimSpace(source)
 	source = strings.TrimSpace(source)

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff