feat: Show config issues in diagnostics (#1087)

This commit is contained in:
James Read 2026-07-25 21:25:11 +01:00 committed by GitHub
commit ecc5c64046
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 2590 additions and 639 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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": {

View File

@ -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"

View File

@ -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;
};
/**

File diff suppressed because one or more lines are too long

View File

@ -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
})
}
}

View File

@ -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, onUnmounted } 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-source'), 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
}
@ -263,85 +311,12 @@ async function copyBrowserInfo () {
onMounted(() => {
fetchDiagnostics()
window.addEventListener('EventConfigChanged', fetchDiagnostics)
window.addEventListener('EventEntityChanged', fetchDiagnostics)
})
onUnmounted(() => {
window.removeEventListener('EventConfigChanged', fetchDiagnostics)
window.removeEventListener('EventEntityChanged', 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>

View File

@ -32,7 +32,7 @@ describe('config: dashboards with basic fieldsets', function () {
await openSidebar()
const navigationLinks = await getNavigationLinks()
assert.equal(navigationLinks.length, 5, 'Expected the nav to only have 5 links') // test dashboard + logs + diagnostics + entities + separator
assert.equal(navigationLinks.length, 4, 'Expected the nav to only have 4 links') // test dashboard + entities + logs + diagnostics
const firstLink = await navigationLinks[0]
@ -44,7 +44,7 @@ describe('config: dashboards with basic fieldsets', function () {
// Check that we have the expected number of fieldsets
const dashboardRows = await webdriver.findElements(By.css('.dashboard-row'))
expect(dashboardRows).to.have.length(3, 'Expected 3 dashboard rows total')
// Check that we have fieldsets with the expected titles
const fieldsetTitles = []
for (let i = 0; i < dashboardRows.length; i++) {
@ -54,7 +54,7 @@ describe('config: dashboards with basic fieldsets', function () {
fieldsetTitles.push(title)
}
}
// We should have fieldsets for: Fieldset 1, Fieldset 2, and Actions fieldsets
expect(fieldsetTitles).to.include('Fieldset 1')
expect(fieldsetTitles).to.include('Fieldset 2')

View File

@ -32,6 +32,6 @@ describe('config: empty dashboards are hidden', function () {
const navigationLinks = await getNavigationLinks()
expect(navigationLinks).to.not.be.empty
expect(navigationLinks.length).to.be.equal(4, 'Expected the nav to only have 4 links')
expect(navigationLinks.length).to.be.equal(3, 'Expected the nav to only have 3 links') // entities + logs + diagnostics
})
})

View File

@ -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": "Aktion",
"diagnostics.config-issue-argument": "Argument",
"diagnostics.config-issue-code": "Code",
"diagnostics.config-issue-config-file": "Konfigurationsdatei",
"diagnostics.config-issue-detail": "Detail",
"diagnostics.config-issue-message": "Meldung",
"diagnostics.config-issue-severity": "Schweregrad",
"diagnostics.config-issue-source": "Quelle",
"diagnostics.config-issues": "Konfigurationsprobleme",
"diagnostics.config-issues-description": "In der aktuellen OliveTin-Konfiguration erkannte Probleme. Beheben Sie diese in Ihren Konfigurationsdateien und laden Sie OliveTin neu.",
"diagnostics.config-issues-none": "Keine Konfigurationsprobleme erkannt.",
"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": "Acción",
"diagnostics.config-issue-argument": "Argumento",
"diagnostics.config-issue-code": "Código",
"diagnostics.config-issue-config-file": "Archivo de configuración",
"diagnostics.config-issue-detail": "Detalle",
"diagnostics.config-issue-message": "Mensaje",
"diagnostics.config-issue-severity": "Severidad",
"diagnostics.config-issue-source": "Origen",
"diagnostics.config-issues": "Problemas de configuración",
"diagnostics.config-issues-description": "Problemas detectados en la configuración actual de OliveTin. Corríjalos en sus archivos de configuración y vuelva a cargar OliveTin.",
"diagnostics.config-issues-none": "No se detectaron problemas de configuración.",
"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": "Azione",
"diagnostics.config-issue-argument": "Argomento",
"diagnostics.config-issue-code": "Codice",
"diagnostics.config-issue-config-file": "File di configurazione",
"diagnostics.config-issue-detail": "Dettaglio",
"diagnostics.config-issue-message": "Messaggio",
"diagnostics.config-issue-severity": "Gravità",
"diagnostics.config-issue-source": "Origine",
"diagnostics.config-issues": "Problemi di configurazione",
"diagnostics.config-issues-description": "Problemi rilevati nella configurazione corrente di OliveTin. Correggili nei file di configurazione e ricarica OliveTin.",
"diagnostics.config-issues-none": "Nessun problema di configurazione rilevato.",
"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": "操作",
"diagnostics.config-issue-argument": "参数",
"diagnostics.config-issue-code": "代码",
"diagnostics.config-issue-config-file": "配置文件",
"diagnostics.config-issue-detail": "详情",
"diagnostics.config-issue-message": "消息",
"diagnostics.config-issue-severity": "严重程度",
"diagnostics.config-issue-source": "来源",
"diagnostics.config-issues": "配置问题",
"diagnostics.config-issues-description": "当前 OliveTin 配置中检测到的问题。请在配置文件中修复这些问题并重新加载 OliveTin。",
"diagnostics.config-issues-none": "未检测到配置问题。",
"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": "動作",
"diagnostics.config-issue-argument": "參數",
"diagnostics.config-issue-code": "代碼",
"diagnostics.config-issue-config-file": "設定檔",
"diagnostics.config-issue-detail": "詳細資料",
"diagnostics.config-issue-message": "訊息",
"diagnostics.config-issue-severity": "嚴重程度",
"diagnostics.config-issue-source": "來源",
"diagnostics.config-issues": "設定問題",
"diagnostics.config-issues-description": "目前 OliveTin 設定中偵測到的問題。請在設定檔中修正這些問題並重新載入 OliveTin。",
"diagnostics.config-issues-none": "未偵測到設定問題。",
"diagnostics.copied": "已複製!",
"diagnostics.copy-to-clipboard": "複製到剪貼簿",
"diagnostics.found-config": "找到設定檔 (Config)",

View File

@ -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: Konfigurationsprobleme
diagnostics.config-issues-description: In der aktuellen OliveTin-Konfiguration erkannte Probleme. Beheben Sie diese in Ihren Konfigurationsdateien und laden Sie OliveTin neu.
diagnostics.config-issues-none: Keine Konfigurationsprobleme erkannt.
diagnostics.config-issue-severity: Schweregrad
diagnostics.config-issue-code: Code
diagnostics.config-issue-message: Meldung
diagnostics.config-issue-action: Aktion
diagnostics.config-issue-argument: Argument
diagnostics.config-issue-config-file: Konfigurationsdatei
diagnostics.config-issue-detail: Detail
diagnostics.config-issue-source: Quelle
diagnostics.ssh: SSH
diagnostics.found-key: Schlüssel gefunden
diagnostics.found-config: Konfiguration gefunden

View File

@ -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

View File

@ -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: Problemas de configuración
diagnostics.config-issues-description: Problemas detectados en la configuración actual de OliveTin. Corríjalos en sus archivos de configuración y vuelva a cargar OliveTin.
diagnostics.config-issues-none: No se detectaron problemas de configuración.
diagnostics.config-issue-severity: Severidad
diagnostics.config-issue-code: Código
diagnostics.config-issue-message: Mensaje
diagnostics.config-issue-action: Acción
diagnostics.config-issue-argument: Argumento
diagnostics.config-issue-config-file: Archivo de configuración
diagnostics.config-issue-detail: Detalle
diagnostics.config-issue-source: Origen
diagnostics.ssh: SSH
diagnostics.found-key: Clave encontrada
diagnostics.found-config: Configuración encontrada

View File

@ -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: Problemi di configurazione
diagnostics.config-issues-description: Problemi rilevati nella configurazione corrente di OliveTin. Correggili nei file di configurazione e ricarica OliveTin.
diagnostics.config-issues-none: Nessun problema di configurazione rilevato.
diagnostics.config-issue-severity: Gravità
diagnostics.config-issue-code: Codice
diagnostics.config-issue-message: Messaggio
diagnostics.config-issue-action: Azione
diagnostics.config-issue-argument: Argomento
diagnostics.config-issue-config-file: File di configurazione
diagnostics.config-issue-detail: Dettaglio
diagnostics.config-issue-source: Origine
diagnostics.ssh: SSH
diagnostics.found-key: Chiave trovata
diagnostics.found-config: Configurazione trovata

View File

@ -63,6 +63,17 @@ translations:
diagnostics.get-support: 获取支持 获取支持
diagnostics.get-support-description: 如果您在使用 OliveTin 时遇到问题并希望提交支持请求,从本页面包含服务器诊断将非常有帮助。
diagnostics.where-to-find-help: 在哪里找到帮助
diagnostics.config-issues: 配置问题
diagnostics.config-issues-description: 当前 OliveTin 配置中检测到的问题。请在配置文件中修复这些问题并重新加载 OliveTin。
diagnostics.config-issues-none: 未检测到配置问题。
diagnostics.config-issue-severity: 严重程度
diagnostics.config-issue-code: 代码
diagnostics.config-issue-message: 消息
diagnostics.config-issue-action: 操作
diagnostics.config-issue-argument: 参数
diagnostics.config-issue-config-file: 配置文件
diagnostics.config-issue-detail: 详情
diagnostics.config-issue-source: 来源
diagnostics.ssh: SSH
diagnostics.found-key: 找到密钥
diagnostics.found-config: 找到配置

View File

@ -54,6 +54,17 @@ translations:
diagnostics.get-support: 尋求技術支援
diagnostics.get-support-description: 如果您在使用 OliveTin 時遇到問題並希望提出支援請求,提供此頁面的「伺服器診斷資訊」將會有很大的幫助。
diagnostics.where-to-find-help: 哪裡可以尋求協助
diagnostics.config-issues: 設定問題
diagnostics.config-issues-description: 目前 OliveTin 設定中偵測到的問題。請在設定檔中修正這些問題並重新載入 OliveTin。
diagnostics.config-issues-none: 未偵測到設定問題。
diagnostics.config-issue-severity: 嚴重程度
diagnostics.config-issue-code: 代碼
diagnostics.config-issue-message: 訊息
diagnostics.config-issue-action: 動作
diagnostics.config-issue-argument: 參數
diagnostics.config-issue-config-file: 設定檔
diagnostics.config-issue-detail: 詳細資料
diagnostics.config-issue-source: 來源
diagnostics.ssh: SSH
diagnostics.found-key: 找到金鑰 (Key)
diagnostics.found-config: 找到設定檔 (Config)

View File

@ -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 {

View File

@ -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,
},

View File

@ -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

View File

@ -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 * * *"},
},
}
testServer, client := getNewTestServerAndClient(cfg)
defer testServer.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" }}`,
},
},
},
}
testServer, client := getNewTestServerAndClient(cfg)
defer testServer.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)
testServer, client := getNewTestServerAndClient(cfg)
defer testServer.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"
testServer, client := getNewTestServerAndClient(cfg)
defer testServer.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")
}

View File

@ -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,
}
}

View File

@ -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.

View File

@ -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
})

View File

@ -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).
arg.sanitizeNoType(action)
// Checklist configuration problems are reported by configcheck.
}
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")
}
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"
}
}

View File

@ -0,0 +1,51 @@
package config
import (
"github.com/knadh/koanf/v2"
log "github.com/sirupsen/logrus"
)
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) {
stampConfigKey(k, "actions", configPath)
stampConfigKey(k, "entities", configPath)
}
func stampConfigKey(k *koanf.Koanf, key, configPath string) {
if err := k.Set(key, stampSourceOnMaps(k.Get(key), configPath)); err != nil {
log.WithFields(log.Fields{
"key": key,
"configPath": configPath,
}).Errorf("Failed to persist source stamps: %v", err)
}
}

View File

@ -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)
}

View File

@ -0,0 +1,486 @@
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 := cfg.Include
if !filepath.IsAbs(includePath) {
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)
out = append(out, watcherCreatedDirIssues(action)...)
out = append(out, watcherChangedDirIssues(action)...)
out = append(out, watcherCalendarFileIssues(action)...)
return out
}
func watcherCreatedDirIssues(action *config.Action) []configissues.Issue {
out := make([]configissues.Issue, 0)
for _, dir := range action.ExecOnFileCreatedInDir {
out = append(out, watcherDirIssue(action, dir)...)
}
return out
}
func watcherChangedDirIssues(action *config.Action) []configissues.Issue {
out := make([]configissues.Issue, 0)
for _, dir := range action.ExecOnFileChangedInDir {
out = append(out, watcherDirIssue(action, dir)...)
}
return out
}
func watcherCalendarFileIssues(action *config.Action) []configissues.Issue {
return watcherFileIssue(action, action.ExecOnCalendarFile)
}
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,
}}
}

View File

@ -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
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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()
issueFingerprintValue := issueFingerprint(issue)
for _, existing := range issues {
if issueFingerprint(existing) == issueFingerprintValue {
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)
}

View File

@ -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())
}

View File

@ -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,28 @@ 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{
ConfigFile: ef.SourceFile,
})
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 +104,7 @@ func loadEntityFileJson(filename string, entityname string) {
if err != nil {
log.Errorf("ReadIn: %v", err)
ClearEntitiesOfType(entityname)
return
}
@ -100,13 +119,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 +139,7 @@ func loadEntityFileYaml(filename string, entityname string) {
if err != nil {
log.Errorf("ReadIn: %v", err)
ClearEntitiesOfType(entityname)
return
}
@ -128,21 +149,31 @@ 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) {
ClearEntitiesOfType(entityname)
func replaceEntitiesFromFile(entityname string, data []map[string]any) {
rwmutex.Lock()
defer rwmutex.Unlock()
for i, mapp := range data {
AddEntity(entityname, fmt.Sprintf("%d", i), mapp)
delete(entities, entityname)
if len(data) == 0 {
return
}
for _, l := range listeners {
l()
entities[entityname] = make(entityInstancesByKey, 0)
for i, mapp := range data {
entityKey := fmt.Sprintf("%d", i)
entities[entityname][entityKey] = &Entity{
Data: mapp,
UniqueKey: entityKey,
Title: findEntityTitle(mapp),
}
}
}

View File

@ -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{}
}

View File

@ -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))
}

View File

@ -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()
}

View File

@ -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)
go func() {
for {
processEvent(ctx, watcher)
}
}()
err = watcher.Add(ctx.filedir)
if err != nil {
log.Errorf("Could not create watcher: %v", err)
if err := watcher.Add(ctx.filedir); err != nil {
reportWatcherFailure(ctx, err)
return
}
<-done
for processEvent(ctx, watcher) {
}
}
func processEvent(ctx *watchContext, watcher *fsnotify.Watcher) {
func closeWatcher(watcher *fsnotify.Watcher) {
if err := watcher.Close(); err != nil {
log.Errorf("Failed to close file watcher: %v", err)
}
}
func watchTarget(ctx *watchContext) string {
if ctx.filename == "" {
return ctx.filedir
}
return filepath.Join(ctx.filedir, ctx.filename)
}
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,
})
}
// 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
}
break
case err := <-watcher.Errors:
log.Errorf("Error in fsnotify: %v", err)
return
return handleWatcherEvent(ctx, event, ok)
case err, ok := <-watcher.Errors:
return handleWatcherError(ctx, err, ok)
}
}
func consumeEvent(ok bool, ctx *watchContext) bool {
func handleWatcherEvent(ctx *watchContext, event fsnotify.Event, ok bool) bool {
if !ok {
return false
}
ctx.event = &event
consumeEvent(ctx)
return true
}
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) {

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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,

View File

@ -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)
}

View File

@ -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)