Przeglądaj źródła

feat: Show config issues in diagnostics

jamesread 3 tygodni temu
rodzic
commit
fe1ac20935
41 zmienionych plików z 2509 dodań i 616 usunięć
  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
 
-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:
 
 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
 
 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)
 }
 
+async function handleEntityChangedEvent (j) {
+  await refreshInitAfterConfigChange()
+  window.dispatchEvent(j)
+}
+
 const eventCaseToTypeName = {
   entityChanged: 'EventEntityChanged',
   configChanged: 'EventConfigChanged',
@@ -230,9 +235,13 @@ function handleEvent (msg) {
     case 'EventHeartbeat':
       break
     case 'EventOutputChunk':
-    case 'EventEntityChanged':
       window.dispatchEvent(j)
       break
+    case 'EventEntityChanged':
+      handleEntityChangedEvent(j).catch((err) => {
+        console.error('EventEntityChanged handler failed:', err)
+      })
+      break
     case 'EventExecutionFinished':
     case 'EventExecutionStarted':
       window.dispatchEvent(j)

+ 282 - 237
frontend/package-lock.json

@@ -11,25 +11,25 @@
 			"dependencies": {
 				"@connectrpc/connect": "^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",
-				"@vitejs/plugin-vue": "^6.0.7",
+				"@vitejs/plugin-vue": "^6.0.8",
 				"@xterm/addon-fit": "^0.11.0",
 				"@xterm/addon-web-links": "^0.12.0",
 				"@xterm/xterm": "^6.0.0",
 				"iconify-icon": "^3.0.2",
-				"picocrank": "^1.18.2",
+				"picocrank": "^1.21.0",
 				"standard": "^17.1.2",
 				"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": {
-				"eslint-plugin-vue": "^10.9.2",
+				"eslint-plugin-vue": "^10.10.0",
 				"process": "^0.11.10",
-				"stylelint": "^17.14.0",
+				"stylelint": "^17.14.1",
 				"stylelint-config-standard": "^40.0.0"
 			},
 			"engines": {
@@ -52,13 +52,13 @@
 			}
 		},
 		"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",
 			"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/trace-mapping": "^0.3.28",
 				"@types/jsesc": "^2.5.0",
@@ -69,30 +69,30 @@
 			}
 		},
 		"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",
 			"engines": {
 				"node": "^22.18.0 || >=24.11.0"
 			}
 		},
 		"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",
 			"engines": {
 				"node": "^22.18.0 || >=24.11.0"
 			}
 		},
 		"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",
 			"dependencies": {
-				"@babel/types": "^8.0.0-rc.5"
+				"@babel/types": "^8.0.4"
 			},
 			"bin": {
 				"parser": "bin/babel-parser.js"
@@ -102,13 +102,13 @@
 			}
 		},
 		"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",
 			"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": {
 				"node": "^22.18.0 || >=24.11.0"
@@ -168,13 +168,13 @@
 			"peer": true
 		},
 		"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,
 			"license": "MIT",
 			"dependencies": {
-				"@cacheable/utils": "^2.4.1",
+				"@cacheable/utils": "^2.5.0",
 				"@keyv/bigmap": "^1.3.1",
 				"hookified": "^1.15.1",
 				"keyv": "^5.6.0"
@@ -208,9 +208,9 @@
 			}
 		},
 		"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,
 			"license": "MIT",
 			"dependencies": {
@@ -295,9 +295,9 @@
 			}
 		},
 		"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,
 			"funding": [
 				{
@@ -883,9 +883,9 @@
 			}
 		},
 		"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",
 			"dependencies": {
 				"eslint-visitor-keys": "^3.4.3"
@@ -942,9 +942,9 @@
 			}
 		},
 		"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"
 		},
 		"node_modules/@hugeicons/vue": {
@@ -998,14 +998,14 @@
 			"license": "MIT"
 		},
 		"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",
 			"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": {
 				"node": ">= 22"
@@ -1015,13 +1015,13 @@
 			}
 		},
 		"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",
 			"dependencies": {
-				"@intlify/core-base": "11.4.6",
-				"@intlify/shared": "11.4.6"
+				"@intlify/core-base": "11.4.7",
+				"@intlify/shared": "11.4.7"
 			},
 			"engines": {
 				"node": ">= 22"
@@ -1031,12 +1031,12 @@
 			}
 		},
 		"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",
 			"dependencies": {
-				"@intlify/shared": "11.4.6",
+				"@intlify/shared": "11.4.7",
 				"source-map-js": "^1.0.2"
 			},
 			"engines": {
@@ -1047,9 +1047,9 @@
 			}
 		},
 		"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",
 			"engines": {
 				"node": ">= 22"
@@ -1481,9 +1481,9 @@
 			"license": "ISC"
 		},
 		"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",
 			"dependencies": {
 				"@rolldown/pluginutils": "^1.0.1"
@@ -1497,9 +1497,9 @@
 			}
 		},
 		"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",
 			"dependencies": {
 				"@vue/compiler-sfc": "^3.5.22",
@@ -1524,53 +1524,53 @@
 			}
 		},
 		"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",
 			"dependencies": {
 				"@babel/parser": "^7.29.7",
-				"@vue/shared": "3.5.39",
+				"@vue/shared": "3.5.40",
 				"entities": "^7.0.1",
 				"estree-walker": "^2.0.2",
 				"source-map-js": "^1.2.1"
 			}
 		},
 		"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",
 			"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": {
-			"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",
 			"dependencies": {
 				"@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",
 				"magic-string": "^0.30.21",
-				"postcss": "^8.5.15",
+				"postcss": "^8.5.19",
 				"source-map-js": "^1.2.1"
 			}
 		},
 		"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",
 			"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": {
@@ -1580,71 +1580,69 @@
 			"license": "MIT"
 		},
 		"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",
 			"dependencies": {
-				"@vue/devtools-shared": "^8.1.2",
+				"@vue/devtools-shared": "^8.1.5",
 				"birpc": "^2.6.1",
 				"hookable": "^5.5.3",
 				"perfect-debounce": "^2.0.0"
 			}
 		},
 		"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"
 		},
 		"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",
 			"dependencies": {
-				"@vue/shared": "3.5.39"
+				"@vue/shared": "3.5.40"
 			}
 		},
 		"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",
 			"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": {
-			"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",
 			"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"
 			}
 		},
 		"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",
 			"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": {
-			"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"
 		},
 		"node_modules/@xterm/addon-fit": {
@@ -1974,9 +1972,9 @@
 			"license": "ISC"
 		},
 		"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",
 			"dependencies": {
 				"balanced-match": "^1.0.0",
@@ -2018,14 +2016,14 @@
 			}
 		},
 		"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,
 			"license": "MIT",
 			"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",
 				"keyv": "^5.6.0",
 				"qified": "^0.10.1"
@@ -3015,18 +3013,18 @@
 			}
 		},
 		"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,
 			"license": "MIT",
 			"dependencies": {
-				"@eslint-community/eslint-utils": "^4.4.0",
+				"@eslint-community/eslint-utils": "^4.9.1",
 				"natural-compare": "^1.4.0",
 				"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": {
 				"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -3232,9 +3230,9 @@
 			"license": "MIT"
 		},
 		"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,
 			"funding": [
 				{
@@ -3597,9 +3595,9 @@
 			}
 		},
 		"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,
 			"license": "MIT",
 			"dependencies": {
@@ -3618,9 +3616,9 @@
 			}
 		},
 		"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,
 			"license": "MIT",
 			"engines": {
@@ -4311,9 +4309,9 @@
 			"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
 		},
 		"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": [
 				{
 					"type": "github",
@@ -4934,9 +4932,9 @@
 			"license": "MIT"
 		},
 		"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": [
 				{
 					"type": "github",
@@ -4966,6 +4964,12 @@
 				"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": {
 			"version": "2.1.1",
 			"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
@@ -5268,9 +5272,9 @@
 			"license": "ISC"
 		},
 		"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",
 			"dependencies": {
 				"@hugeicons/core-free-icons": "^4.2.2",
@@ -5400,9 +5404,9 @@
 			}
 		},
 		"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": [
 				{
 					"type": "opencollective",
@@ -5419,7 +5423,7 @@
 			],
 			"license": "MIT",
 			"dependencies": {
-				"nanoid": "^3.3.12",
+				"nanoid": "^3.3.16",
 				"picocolors": "^1.1.1",
 				"source-map-js": "^1.2.1"
 			},
@@ -6233,9 +6237,9 @@
 			}
 		},
 		"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,
 			"funding": [
 				{
@@ -6251,7 +6255,7 @@
 			"dependencies": {
 				"@csstools/css-calc": "^3.2.1",
 				"@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/media-query-list-parser": "^5.0.0",
 				"@csstools/selector-resolve-nested": "^4.0.0",
@@ -6263,9 +6267,9 @@
 				"debug": "^4.4.3",
 				"fast-glob": "^3.3.3",
 				"fastest-levenshtein": "^1.0.16",
-				"file-entry-cache": "^11.1.3",
+				"file-entry-cache": "^11.1.5",
 				"global-modules": "^2.0.0",
-				"globby": "^16.2.0",
+				"globby": "^16.2.1",
 				"globjoin": "^0.1.4",
 				"html-tags": "^5.1.0",
 				"ignore": "^7.0.5",
@@ -6275,12 +6279,12 @@
 				"micromatch": "^4.0.8",
 				"normalize-path": "^3.0.0",
 				"picocolors": "^1.1.1",
-				"postcss": "^8.5.15",
+				"postcss": "^8.5.16",
 				"postcss-safe-parser": "^7.0.1",
 				"postcss-selector-parser": "^7.1.4",
 				"postcss-value-parser": "^4.2.0",
 				"string-width": "^8.2.1",
-				"supports-hyperlinks": "^4.4.0",
+				"supports-hyperlinks": "^4.5.0",
 				"svg-tags": "^1.0.0",
 				"table": "^6.9.0",
 				"write-file-atomic": "^7.0.1"
@@ -6355,23 +6359,23 @@
 			}
 		},
 		"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,
 			"license": "MIT",
 			"dependencies": {
-				"flat-cache": "^6.1.22"
+				"flat-cache": "^6.1.23"
 			}
 		},
 		"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,
 			"license": "MIT",
 			"dependencies": {
-				"cacheable": "^2.3.4",
+				"cacheable": "^2.5.0",
 				"flatted": "^3.4.2",
 				"hookified": "^1.15.0"
 			}
@@ -6431,9 +6435,9 @@
 			}
 		},
 		"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,
 			"license": "MIT",
 			"dependencies": {
@@ -6750,27 +6754,67 @@
 			}
 		},
 		"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",
 			"dependencies": {
 				"@jridgewell/remapping": "^2.3.5",
-				"picomatch": "^4.0.3",
+				"picomatch": "^4.0.4",
 				"webpack-virtual-modules": "^0.6.2"
 			},
 			"engines": {
 				"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": {
-			"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",
 			"dependencies": {
 				"pathe": "^2.0.3",
-				"picomatch": "^4.0.3"
+				"picomatch": "^4.0.4"
 			},
 			"engines": {
 				"node": ">=20.19.0"
@@ -6872,15 +6916,15 @@
 			}
 		},
 		"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",
 			"dependencies": {
 				"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"
 			},
 			"bin": {
@@ -6949,9 +6993,9 @@
 			}
 		},
 		"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",
 			"engines": {
 				"node": ">=12"
@@ -6961,16 +7005,16 @@
 			}
 		},
 		"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",
 			"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": {
 				"typescript": "*"
@@ -7074,14 +7118,14 @@
 			}
 		},
 		"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",
 			"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"
 			},
 			"engines": {
@@ -7095,27 +7139,28 @@
 			}
 		},
 		"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",
 			"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",
 				"chokidar": "^5.0.0",
 				"json5": "^2.2.3",
-				"local-pkg": "^1.1.2",
+				"local-pkg": "^1.2.1",
 				"magic-string": "^0.30.21",
 				"mlly": "^1.8.2",
 				"muggle-string": "^0.4.1",
+				"nostics": "^1.1.4",
 				"pathe": "^2.0.3",
-				"picomatch": "^4.0.3",
+				"picomatch": "^4.0.5",
 				"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"
 			},
 			"funding": {
@@ -7123,10 +7168,10 @@
 			},
 			"peerDependencies": {
 				"@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": {
 				"@pinia/colada": {
@@ -7144,12 +7189,12 @@
 			}
 		},
 		"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",
 			"dependencies": {
-				"@vue/devtools-kit": "^8.1.2"
+				"@vue/devtools-kit": "^8.1.5"
 			}
 		},
 		"node_modules/vue-router/node_modules/json5": {
@@ -7165,9 +7210,9 @@
 			}
 		},
 		"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",
 			"engines": {
 				"node": ">=12"
@@ -7320,13 +7365,13 @@
 			}
 		},
 		"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,
 			"license": "Apache-2.0",
 			"engines": {
-				"node": ">=12"
+				"node": ">=18"
 			}
 		},
 		"node_modules/yaml": {

+ 9 - 9
frontend/package.json

@@ -5,9 +5,9 @@
 	"repository": "https://github.com/OliveTin/OliveTin",
 	"source": "index.html",
 	"devDependencies": {
-		"eslint-plugin-vue": "^10.9.2",
+		"eslint-plugin-vue": "^10.10.0",
 		"process": "^0.11.10",
-		"stylelint": "^17.14.0",
+		"stylelint": "^17.14.1",
 		"stylelint-config-standard": "^40.0.0"
 	},
 	"scripts": {
@@ -25,20 +25,20 @@
 	"dependencies": {
 		"@connectrpc/connect": "^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",
-		"@vitejs/plugin-vue": "^6.0.7",
+		"@vitejs/plugin-vue": "^6.0.8",
 		"@xterm/addon-fit": "^0.11.0",
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/xterm": "^6.0.0",
 		"iconify-icon": "^3.0.2",
-		"picocrank": "^1.18.2",
+		"picocrank": "^1.21.0",
 		"standard": "^17.1.2",
 		"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": {
 		"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)
 /* eslint-disable */
 
@@ -1666,6 +1666,57 @@ export declare type GetDiagnosticsRequest = Message<"olivetin.api.v1.GetDiagnost
  */
 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
  */
@@ -1679,6 +1730,11 @@ export declare type GetDiagnosticsResponse = Message<"olivetin.api.v1.GetDiagnos
    * @generated from field: string SshFoundConfig = 2;
    */
   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;
    */
   showNavigateOnStartIcons: boolean;
+
+  /**
+   * @generated from field: int32 config_issue_count = 26;
+   */
+  configIssueCount: number;
 };
 
 /**

Plik diff jest za duży
+ 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 || []
 
-  if (typeof navigation.value.clear === 'function') {
-    navigation.value.clear()
+  if (typeof navigation.value.clearNavigationLinks === 'function') {
+    navigation.value.clearNavigationLinks()
   }
 
   for (const rootDashboard of rootDashboards) {
@@ -367,7 +367,10 @@ function renderNavigation () {
   }
 
   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>
+  <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')">
     <p>
       {{ t('diagnostics.get-support-description') }}
@@ -84,19 +108,41 @@
 </template>
 
 <script setup>
-import { ref, onMounted } from 'vue'
+import { ref, computed, onMounted } from 'vue'
 import Section from 'picocrank/vue/components/Section.vue'
+import Table from 'picocrank/vue/components/Table.vue'
 import { useI18n } from 'vue-i18n'
 
 const { t, locale } = useI18n()
 
 const diagnostics = ref({})
+const configIssues = ref([])
 const loading = ref(false)
 const serverDiagnostics = ref('')
 const browserInfo = ref('')
 const serverDiagnosticsCopied = 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 () {
   loading.value = true
 
@@ -106,12 +152,14 @@ async function fetchDiagnostics () {
       sshFoundKey: response.SshFoundKey,
       sshFoundConfig: response.SshFoundConfig
     }
+    configIssues.value = response.configIssues || []
   } catch (err) {
     console.error('Failed to fetch diagnostics:', err)
     diagnostics.value = {
       sshFoundKey: t('diagnostics.unknown'),
       sshFoundConfig: t('diagnostics.unknown')
     }
+    configIssues.value = []
   }
   loading.value = false
 }
@@ -265,83 +313,3 @@ onMounted(() => {
   fetchDiagnostics()
 })
 </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",
             "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.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.copy-to-clipboard": "In Zwischenablage kopieren",
             "diagnostics.found-config": "Konfiguration gefunden",
@@ -84,6 +95,17 @@
             "connected": "Connected",
             "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.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.copy-to-clipboard": "Copy to Clipboard",
             "diagnostics.found-config": "Found Config",
@@ -163,6 +185,17 @@
             "connected": "Conectado",
             "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.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.copy-to-clipboard": "Copiar al portapapeles",
             "diagnostics.found-config": "Configuración encontrada",
@@ -242,6 +275,17 @@
             "connected": "Connesso",
             "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.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.copy-to-clipboard": "Copia negli appunti",
             "diagnostics.found-config": "Configurazione trovata",
@@ -321,6 +365,17 @@
             "connected": "已连接",
             "diagnostics.browser-info": "浏览器信息",
             "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.copy-to-clipboard": "复制到剪贴板",
             "diagnostics.found-config": "找到配置",
@@ -400,6 +455,17 @@
             "connected": "已連線",
             "diagnostics.browser-info": "瀏覽器資訊",
             "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.copy-to-clipboard": "複製到剪貼簿",
             "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-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.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.found-key: Schlüssel gefunden
   diagnostics.found-config: Konfiguration gefunden

+ 11 - 0
lang/en.yaml

@@ -54,6 +54,17 @@ translations:
   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.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.found-key: Found Key
   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-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.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.found-key: Clave 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-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.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.found-key: Chiave trovata
   diagnostics.found-config: Configurazione trovata

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

@@ -63,6 +63,17 @@ translations:
   diagnostics.get-support: 获取支持 获取支持
   diagnostics.get-support-description: 如果您在使用 OliveTin 时遇到问题并希望提交支持请求,从本页面包含服务器诊断将非常有帮助。
   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.found-key: 找到密钥
   diagnostics.found-config: 找到配置

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

@@ -54,6 +54,17 @@ translations:
   diagnostics.get-support: 尋求技術支援
   diagnostics.get-support-description: 如果您在使用 OliveTin 時遇到問題並希望提出支援請求,提供此頁面的「伺服器診斷資訊」將會有很大的幫助。
   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.found-key: 找到金鑰 (Key)
   diagnostics.found-config: 找到設定檔 (Config)

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

@@ -379,9 +379,21 @@ message LogoutResponse {
 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 {
 	string SshFoundKey = 1;
 	string SshFoundConfig = 2;
+	repeated ConfigIssue config_issues = 3;
 }
 
 message InitRequest {}
@@ -412,6 +424,7 @@ message InitResponse {
 	bool login_required = 23;
 	repeated string available_themes = 24; // List of available theme names
 	bool show_navigate_on_start_icons = 25;
+	int32 config_issue_count = 26;
 }
 
 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}
 }
 
+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 {
 	state          protoimpl.MessageState `protogen:"open.v1"`
 	SshFoundKey    string                 `protobuf:"bytes,1,opt,name=SshFoundKey,proto3" json:"SshFoundKey,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
 	sizeCache      protoimpl.SizeCache
 }
 
 func (x *GetDiagnosticsResponse) Reset() {
 	*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.StoreMessageInfo(mi)
 }
@@ -3893,7 +3994,7 @@ func (x *GetDiagnosticsResponse) String() string {
 func (*GetDiagnosticsResponse) ProtoMessage() {}
 
 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 {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3906,7 +4007,7 @@ func (x *GetDiagnosticsResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetDiagnosticsResponse.ProtoReflect.Descriptor instead.
 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 {
@@ -3923,6 +4024,13 @@ func (x *GetDiagnosticsResponse) GetSshFoundConfig() string {
 	return ""
 }
 
+func (x *GetDiagnosticsResponse) GetConfigIssues() []*ConfigIssue {
+	if x != nil {
+		return x.ConfigIssues
+	}
+	return nil
+}
+
 type InitRequest struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	unknownFields protoimpl.UnknownFields
@@ -3931,7 +4039,7 @@ type InitRequest struct {
 
 func (x *InitRequest) Reset() {
 	*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.StoreMessageInfo(mi)
 }
@@ -3943,7 +4051,7 @@ func (x *InitRequest) String() string {
 func (*InitRequest) ProtoMessage() {}
 
 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 {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -3956,7 +4064,7 @@ func (x *InitRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use InitRequest.ProtoReflect.Descriptor instead.
 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 {
@@ -3986,13 +4094,14 @@ type InitResponse struct {
 	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
 	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
 	sizeCache                 protoimpl.SizeCache
 }
 
 func (x *InitResponse) Reset() {
 	*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.StoreMessageInfo(mi)
 }
@@ -4004,7 +4113,7 @@ func (x *InitResponse) String() string {
 func (*InitResponse) ProtoMessage() {}
 
 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 {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4017,7 +4126,7 @@ func (x *InitResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use InitResponse.ProtoReflect.Descriptor instead.
 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 {
@@ -4195,6 +4304,13 @@ func (x *InitResponse) GetShowNavigateOnStartIcons() bool {
 	return false
 }
 
+func (x *InitResponse) GetConfigIssueCount() int32 {
+	if x != nil {
+		return x.ConfigIssueCount
+	}
+	return 0
+}
+
 type AdditionalLink struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Title         string                 `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
@@ -4205,7 +4321,7 @@ type AdditionalLink struct {
 
 func (x *AdditionalLink) Reset() {
 	*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.StoreMessageInfo(mi)
 }
@@ -4217,7 +4333,7 @@ func (x *AdditionalLink) String() string {
 func (*AdditionalLink) ProtoMessage() {}
 
 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 {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4230,7 +4346,7 @@ func (x *AdditionalLink) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use AdditionalLink.ProtoReflect.Descriptor instead.
 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 {
@@ -4258,7 +4374,7 @@ type OAuth2Provider struct {
 
 func (x *OAuth2Provider) Reset() {
 	*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.StoreMessageInfo(mi)
 }
@@ -4270,7 +4386,7 @@ func (x *OAuth2Provider) String() string {
 func (*OAuth2Provider) ProtoMessage() {}
 
 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 {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4283,7 +4399,7 @@ func (x *OAuth2Provider) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use OAuth2Provider.ProtoReflect.Descriptor instead.
 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 {
@@ -4316,7 +4432,7 @@ type GetActionBindingRequest struct {
 
 func (x *GetActionBindingRequest) Reset() {
 	*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.StoreMessageInfo(mi)
 }
@@ -4328,7 +4444,7 @@ func (x *GetActionBindingRequest) String() string {
 func (*GetActionBindingRequest) ProtoMessage() {}
 
 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 {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4341,7 +4457,7 @@ func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetActionBindingRequest.ProtoReflect.Descriptor instead.
 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 {
@@ -4361,7 +4477,7 @@ type GetActionBindingResponse struct {
 
 func (x *GetActionBindingResponse) Reset() {
 	*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.StoreMessageInfo(mi)
 }
@@ -4373,7 +4489,7 @@ func (x *GetActionBindingResponse) String() string {
 func (*GetActionBindingResponse) ProtoMessage() {}
 
 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 {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4386,7 +4502,7 @@ func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetActionBindingResponse.ProtoReflect.Descriptor instead.
 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 {
@@ -4415,7 +4531,7 @@ type GetEntitiesRequest struct {
 
 func (x *GetEntitiesRequest) Reset() {
 	*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.StoreMessageInfo(mi)
 }
@@ -4427,7 +4543,7 @@ func (x *GetEntitiesRequest) String() string {
 func (*GetEntitiesRequest) ProtoMessage() {}
 
 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 {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4440,7 +4556,7 @@ func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetEntitiesRequest.ProtoReflect.Descriptor instead.
 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 {
@@ -4480,7 +4596,7 @@ type GetEntitiesResponse struct {
 
 func (x *GetEntitiesResponse) Reset() {
 	*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.StoreMessageInfo(mi)
 }
@@ -4492,7 +4608,7 @@ func (x *GetEntitiesResponse) String() string {
 func (*GetEntitiesResponse) ProtoMessage() {}
 
 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 {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4505,7 +4621,7 @@ func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetEntitiesResponse.ProtoReflect.Descriptor instead.
 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 {
@@ -4529,7 +4645,7 @@ type EntityDefinition struct {
 
 func (x *EntityDefinition) Reset() {
 	*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.StoreMessageInfo(mi)
 }
@@ -4541,7 +4657,7 @@ func (x *EntityDefinition) String() string {
 func (*EntityDefinition) ProtoMessage() {}
 
 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 {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4554,7 +4670,7 @@ func (x *EntityDefinition) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EntityDefinition.ProtoReflect.Descriptor instead.
 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 {
@@ -4609,7 +4725,7 @@ type EntityProperty struct {
 
 func (x *EntityProperty) Reset() {
 	*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.StoreMessageInfo(mi)
 }
@@ -4621,7 +4737,7 @@ func (x *EntityProperty) String() string {
 func (*EntityProperty) ProtoMessage() {}
 
 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 {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4634,7 +4750,7 @@ func (x *EntityProperty) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EntityProperty.ProtoReflect.Descriptor instead.
 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 {
@@ -4661,7 +4777,7 @@ type GetEntityRequest struct {
 
 func (x *GetEntityRequest) Reset() {
 	*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.StoreMessageInfo(mi)
 }
@@ -4673,7 +4789,7 @@ func (x *GetEntityRequest) String() string {
 func (*GetEntityRequest) ProtoMessage() {}
 
 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 {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4686,7 +4802,7 @@ func (x *GetEntityRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetEntityRequest.ProtoReflect.Descriptor instead.
 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 {
@@ -4712,7 +4828,7 @@ type RestartActionRequest struct {
 
 func (x *RestartActionRequest) Reset() {
 	*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.StoreMessageInfo(mi)
 }
@@ -4724,7 +4840,7 @@ func (x *RestartActionRequest) String() string {
 func (*RestartActionRequest) ProtoMessage() {}
 
 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 {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4737,7 +4853,7 @@ func (x *RestartActionRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RestartActionRequest.ProtoReflect.Descriptor instead.
 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 {
@@ -5044,11 +5160,22 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\x04hash\x18\x01 \x01(\tR\x04hash\"\x0f\n" +
 	"\rLogoutRequest\"\x10\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" +
 	"\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" +
 	"\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" +
 	"\x0elogin_required\x18\x17 \x01(\bR\rloginRequired\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" +
 	"\x05title\x18\x01 \x01(\tR\x05title\x12\x10\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
 }
 
-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{
 	(*Action)(nil),                          // 0: olivetin.api.v1.Action
 	(*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
 	(*LogoutResponse)(nil),                  // 63: olivetin.api.v1.LogoutResponse
 	(*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{
 	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
 	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
-	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
-	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
 	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
@@ -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
 	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
-	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
 	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
@@ -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
 	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
-	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() }
@@ -5372,7 +5502,7 @@ func file_olivetin_api_v1_olivetin_proto_init() {
 			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)),
 			NumEnums:      0,
-			NumMessages:   85,
+			NumMessages:   86,
 			NumExtensions: 0,
 			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{
 		SshFoundKey:    installationinfo.Runtime.SshFoundKey,
 		SshFoundConfig: installationinfo.Runtime.SshFoundConfig,
+		ConfigIssues:   api.buildConfigIssuesForUser(user),
 	}
 	return connect.NewResponse(res), nil
 }
@@ -1242,6 +1243,7 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
 		LoginRequired:             loginRequired,
 		AvailableThemes:           discoverAvailableThemes(api.cfg),
 		ShowNavigateOnStartIcons:  api.cfg.ShowNavigateOnStartIcons,
+		ConfigIssueCount:          configIssueCountForUser(api, user),
 	}
 
 	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"`
 	Groups                 []string         `koanf:"groups"`
 	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 {
@@ -110,6 +112,8 @@ type EntityFile struct {
 	Name       string           `koanf:"name"`
 	Icon       string           `koanf:"icon"`
 	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.

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

@@ -1,6 +1,7 @@
 package config
 
 import (
+	"fmt"
 	"os"
 	"path/filepath"
 	"reflect"
@@ -8,6 +9,7 @@ import (
 	"sort"
 	"strings"
 
+	"github.com/OliveTin/OliveTin/internal/configissues"
 	"github.com/go-viper/mapstructure/v2"
 	"github.com/knadh/koanf/parsers/yaml"
 	"github.com/knadh/koanf/providers/file"
@@ -40,6 +42,8 @@ func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) {
 		"configPath": configPath,
 	}).Info("Appending cfg source")
 
+	configissues.BeginConfigLoad()
+	stampLoadedConfigSources(k, configPath)
 	loadIncludedConfigsFromDir(k, configPath)
 
 	if !unmarshalRoot(k, cfg) {
@@ -172,7 +176,7 @@ func listYamlFiles(includePath string) ([]string, bool) {
 func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
 	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)
 		return
 	}
@@ -182,8 +186,15 @@ func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
 	}).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.
-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{})
 	destSlice, ok2 := destActions.([]interface{})
 	if ok1 && ok2 {
@@ -194,10 +205,11 @@ func mergeActionsWhenBothExist(srcActions interface{}, destActions interface{},
 }
 
 // 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 {
-		mergeActionsWhenBothExist(srcActions, destActions, dest)
+		mergeActionsWhenBothExist(srcActions, destActions, dest, sourceFile)
 	} else {
+		stampSourceOnMaps(srcActions, sourceFile)
 		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.
-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{})
 	destSlice, ok2 := destEntities.([]interface{})
 	if ok1 && ok2 {
@@ -234,17 +247,18 @@ func mergeEntitiesWhenBothExist(srcEntities interface{}, destEntities interface{
 }
 
 // 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 {
-		mergeEntitiesWhenBothExist(srcEntities, destEntities, dest)
+		mergeEntitiesWhenBothExist(srcEntities, destEntities, dest, sourceFile)
 	} else {
+		stampSourceOnMaps(srcEntities, sourceFile)
 		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 {
-		mergeActionsFromSource(srcActions, dest)
+		mergeActionsFromSource(srcActions, dest, sourceFile)
 	}
 
 	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 {
-		mergeEntitiesFromSource(srcEntities, dest)
+		mergeEntitiesFromSource(srcEntities, dest, sourceFile)
 	}
 
 	return nil
@@ -285,7 +299,12 @@ func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error
 		val, set := os.LookupEnv(key)
 		log.Debugf("Environment variable %q: set=%v, value=%q", key, set, val)
 		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
 	})

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

@@ -5,6 +5,7 @@ import (
 	"strings"
 	"text/template"
 
+	"github.com/OliveTin/OliveTin/internal/configissues"
 	"github.com/OliveTin/OliveTin/internal/env"
 	"github.com/google/uuid"
 	log "github.com/sirupsen/logrus"
@@ -29,7 +30,6 @@ func (cfg *Config) Sanitize() {
 	cfg.sanitizeDashboardsForInlineActions()
 
 	cfg.sanitizeActionGroups()
-	cfg.sanitizeActionGroupReferences()
 	cfg.sanitizeEntities()
 
 	if err := cfg.validateReservedActionArgumentNames(); err != nil {
@@ -238,7 +238,7 @@ func (action *Action) sanitize(cfg *Config) {
 	action.Groups = dedupeStrings(action.Groups)
 
 	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() {
 	for _, entityFile := range cfg.Entities {
 		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() {
 	if cfg.AuthRequireGuestsToLogin {
 		log.Infof("AuthRequireGuestsToLogin is enabled. All defaultPermissions will be set to false")
@@ -505,7 +479,7 @@ func (cfg *Config) sanitizeOnClickDefaults() {
 	cfg.DefaultPopupOnStart = cfg.DefaultOnClick
 }
 
-func (arg *ActionArgument) sanitize() {
+func (arg *ActionArgument) sanitize(action *Action) {
 	if arg.Title == "" {
 		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 == "" {
-		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"
 	}
 }

+ 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) {
-	baseDir := resolveEntitiesBaseDir(cfg.GetDir())
+	baseDir := ResolveEntitiesBaseDir(cfg.GetDir())
 	for i := range cfg.Entities { // #337 - iterate by key, not by value
 		ef := cfg.Entities[i]
 		watchAndLoadEntity(baseDir, ef)
 	}
 }
 
+// ResolveEntitiesBaseDir returns the directory used to resolve relative entity file paths.
+func ResolveEntitiesBaseDir(configDir string) string {
+	return resolveEntitiesBaseDir(configDir)
+}
+
 //gocyclo:ignore
 func resolveEntitiesBaseDir(configDir string) string {
 	absConfigDir, err := filepath.Abs(configDir)
@@ -64,15 +69,26 @@ func watchAndLoadEntity(baseDir string, ef *config.EntityFile) {
 		p = filepath.Join(baseDir, p)
 		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)
 }
 
 func loadEntityFile(filename string, entityname string) {
+	defer func() {
+		MarkEntityLoadAttempted(entityname)
+		notifyEntityListeners()
+	}()
+
 	if strings.HasSuffix(filename, ".json") {
 		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 {
 		log.Errorf("ReadIn: %v", err)
+		ClearEntitiesOfType(entityname)
 		return
 	}
 
@@ -100,13 +117,14 @@ func loadEntityFileJson(filename string, entityname string) {
 
 		if err != nil {
 			log.Errorf("%v", err)
+			ClearEntitiesOfType(entityname)
 			return
 		}
 
 		data = append(data, d)
 	}
 
-	updateSvFromFile(entityname, data)
+	replaceEntitiesFromFile(entityname, data)
 }
 
 func loadEntityFileYaml(filename string, entityname string) {
@@ -119,6 +137,7 @@ func loadEntityFileYaml(filename string, entityname string) {
 
 	if err != nil {
 		log.Errorf("ReadIn: %v", err)
+		ClearEntitiesOfType(entityname)
 		return
 	}
 
@@ -128,22 +147,19 @@ func loadEntityFileYaml(filename string, entityname string) {
 
 	if err != nil {
 		log.Errorf("Unmarshal: %v", err)
+		ClearEntitiesOfType(entityname)
 		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)
 
 	for i, mapp := range data {
 		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 (
 	"crypto/sha256"
 	"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"
-	log "github.com/sirupsen/logrus"
 )
 
 func (e *Executor) FindBindingByID(id string) *ActionBinding {
@@ -40,40 +42,54 @@ type RebuildActionMapRequest struct {
 	dashboardTargets *dashboardTargetIndex
 }
 
-func validateArgumentDefaults(cfg *config.Config) {
+func validateArgumentDefaults(cfg *config.Config) []configissues.Issue {
 	if cfg == nil {
-		return
+		return nil
 	}
+	out := make([]configissues.Issue, 0)
 	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 {
-		return
+		return nil
 	}
+	out := make([]configissues.Issue, 0)
 	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 == "" {
-		return
+		return nil
+	}
+	if strings.Contains(arg.Default, "{{") {
+		return 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() {
-	validateArgumentDefaults(e.Cfg)
+	defaultIssues := validateArgumentDefaults(e.Cfg)
 
 	e.MapActionBindingsLock.Lock()
 
@@ -94,6 +110,8 @@ func (e *Executor) RebuildActionMap() {
 
 	e.MapActionBindingsLock.Unlock()
 
+	configcheck.Rebuild(e.Cfg, defaultIssues...)
+
 	for _, l := range e.copyListeners() {
 		l.OnActionMapRebuilt()
 	}

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

@@ -1,12 +1,14 @@
 package filehelper
 
 import (
-	"github.com/fsnotify/fsnotify"
-	log "github.com/sirupsen/logrus"
+	"fmt"
 	"path/filepath"
+	"sync"
 	"time"
 
-	"sync"
+	"github.com/OliveTin/OliveTin/internal/configissues"
+	"github.com/fsnotify/fsnotify"
+	log "github.com/sirupsen/logrus"
 )
 
 var (
@@ -28,33 +30,44 @@ const (
 	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 {
 	filename        string
 	filedir         string
 	callback        func(filename string)
 	interestedEvent fsnotify.Op
 	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{
 		filedir:         fullpath,
 		filename:        "",
 		callback:        callback,
 		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{
 		filedir:         fullpath,
 		filename:        "",
 		callback:        callback,
 		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)
 	filedir := filepath.Dir(fullpath)
 
@@ -63,69 +76,94 @@ func WatchFileWrite(fullpath string, callback func(filename string)) {
 		filename:        filename,
 		callback:        callback,
 		interestedEvent: fsnotify.Write,
+		meta:            meta,
 	})
 }
 
 func watchPath(ctx *watchContext) {
 	watcher, err := fsnotify.NewWatcher()
-
 	if err != nil {
-		log.Errorf("Could not watch for files being created: %v", err)
+		reportWatcherFailure(ctx, err)
 		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 {
 	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 {
 		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 {
 		log.Tracef("fsnotify irreleventa event different file %+v", ctx.event)
-		return true
+		return
 	}
 
 	consumeRelevantEvents(ctx)
-
-	return true
 }
 
 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)
 			}
 
-			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)
 		}

+ 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) {
 				filehelper.WatchDirectoryCreate(dir, func(filename string) {
 					scheduleExec(act, cfg, ex, filename)
-				})
+				}, watchMetaForAction(act))
 			}(action, dirname)
 		}
 		for _, dirname := range action.ExecOnFileChangedInDir {
@@ -27,12 +27,20 @@ func WatchFilesInDirectory(cfg *config.Config, ex *executor.Executor) {
 			go func(act *config.Action, dir string) {
 				filehelper.WatchDirectoryWrite(dir, func(filename string) {
 					scheduleExec(act, cfg, ex, filename)
-				})
+				}, watchMetaForAction(act))
 			}(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) {
 	args := map[string]string{
 		"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
 }
 
+// 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 {
 	source = strings.TrimSpace(source)

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików