fmt: Code style and linter

This commit is contained in:
jamesread 2026-07-18 22:46:05 +01:00
parent e2bf82ff9c
commit 25b80e1460
66 changed files with 4062 additions and 2775 deletions

View File

@ -18,6 +18,7 @@ Please put a X in the boxes as evidence of reading through the checklist.
- [ ] I followed the "1 logical change" rule.
- [ ] I have forked the project, and raised this PR on a feature branch.
- [ ] I ran the `pre-commit` hooks, and my commit message was validated.
- [ ] I installed the pre-push hook (`pre-commit install --hook-type pre-push`) and integration tests pass on push when relevant files changed.
- [ ] `make -wC service compile` runs without any issues.
- [ ] `make -wC service codestyle` runs without any issues.
- [ ] `make -wC service unittests` runs without any issues.

View File

@ -38,32 +38,33 @@ repos:
entry: make service-codestyle
language: system
pass_filenames: false
always_run: true
files: ^(service/|proto/|lang/)
- id: frontend-codestyle
name: frontend-codestyle
entry: make frontend-codestyle
language: system
pass_filenames: false
always_run: true
files: ^frontend/
- id: service-unittests
name: service-unittests
entry: make service-unittests
language: system
pass_filenames: false
always_run: true
files: ^(service/|proto/|lang/)
- id: service-build
name: service-build
entry: make service
language: system
pass_filenames: false
always_run: true
files: ^(service/|proto/|lang/)
- id: it
name: integration-tests
entry: make it
language: system
pass_filenames: false
always_run: true
stages: [pre-push]
files: ^(service/|frontend/|integration-tests/|proto/|Makefile)

View File

@ -43,6 +43,7 @@ choco install git go protoc make python nodejs-lts -y && pip install pre-commit
git clone https://github.com/OliveTin/OliveTin.git
cd OliveTin
pre-commit install
pre-commit install --hook-type pre-push
# Step3: compile binary for current dev env (OS, ARCH)
# `make proto` will also run `make go-tools`, which installs "buf". This binary

View File

@ -4,12 +4,22 @@
"es2021": true
},
"extends": [
"plugin:vue/recommended",
"standard"
],
"parser": "vue-eslint-parser",
"parserOptions": {
"ecmaVersion": 12,
"sourceType": "module"
"sourceType": "module",
"parser": {
"js": "espree"
}
},
"rules": {
"vue/multi-word-component-names": "off",
"vue/require-default-prop": "off",
"vue/no-v-html": "warn",
"no-tabs": "off",
"no-mixed-spaces-and-tabs": "off"
}
}

View File

@ -4,7 +4,7 @@ endef
codestyle:
npm install
npx eslint --fix main.js js/*
npx eslint --fix main.js js/* resources/vue
npx stylelint style.css
clean:

View File

@ -11,24 +11,25 @@
"dependencies": {
"@connectrpc/connect": "^2.1.2",
"@connectrpc/connect-web": "^2.1.2",
"@hugeicons/core-free-icons": "^4.2.1",
"@hugeicons/vue": "^1.0.6",
"@hugeicons/core-free-icons": "^4.2.2",
"@hugeicons/vue": "^1.0.7",
"@vitejs/plugin-vue": "^6.0.7",
"@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.17.0",
"picocrank": "^1.18.2",
"standard": "^17.1.2",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.0.16",
"vue": "^3.5.38",
"vite": "^8.1.3",
"vue": "^3.5.39",
"vue-i18n": "^11.4.6",
"vue-router": "^5.1.0"
},
"devDependencies": {
"eslint-plugin-vue": "^10.9.2",
"process": "^0.11.10",
"stylelint": "^17.13.0",
"stylelint": "^17.14.0",
"stylelint-config-standard": "^40.0.0"
},
"engines": {
@ -36,13 +37,13 @@
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-validator-identifier": "^7.28.5",
"@babel/helper-validator-identifier": "^7.29.7",
"js-tokens": "^4.0.0",
"picocolors": "^1.1.1"
},
@ -409,20 +410,20 @@
}
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.1",
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"license": "MIT",
"optional": true,
"dependencies": {
@ -430,9 +431,9 @@
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
"integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
"license": "MIT",
"optional": true,
"dependencies": {
@ -941,15 +942,15 @@
}
},
"node_modules/@hugeicons/core-free-icons": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.2.1.tgz",
"integrity": "sha512-75jYZKYyA9VwS35YRmmGUGzFedbY+Fl0Vxx5FzXR2CGDlIhNRumFeVqaaKoClf2MeYEJwPAVMEL9RwCYtOgnSw==",
"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==",
"license": "MIT"
},
"node_modules/@hugeicons/vue": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/@hugeicons/vue/-/vue-1.0.6.tgz",
"integrity": "sha512-T1Wbuk2qDdoE2xcY3CDtwMOKewwIYDE7JoMVLeuAV4cbOx5CFtToHCQAmLgVPIFGGZUsV2nN52lBkts9upvePw==",
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/@hugeicons/vue/-/vue-1.0.7.tgz",
"integrity": "sha512-oUE0ztUNYbsMFpMgbV3bsxQBObNkeouomUThFuFPzYC5eV++VTYonStsltJu2MoLMkxV8mzpoVvIbuMPBZSN0w==",
"license": "MIT",
"peerDependencies": {
"vue": "^2.6.0 || ^3.0.0"
@ -1110,13 +1111,13 @@
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
"integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.2"
"@tybys/wasm-util": "^0.10.3"
},
"funding": {
"type": "github",
@ -1160,18 +1161,18 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.133.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
"version": "0.139.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
"integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
"integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
"cpu": [
"arm64"
],
@ -1185,9 +1186,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
"integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
"cpu": [
"arm64"
],
@ -1201,9 +1202,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
"integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
"cpu": [
"x64"
],
@ -1217,9 +1218,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
"integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
"cpu": [
"x64"
],
@ -1233,9 +1234,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
"integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
"cpu": [
"arm"
],
@ -1249,9 +1250,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
"integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
"cpu": [
"arm64"
],
@ -1265,9 +1266,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
"integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
"cpu": [
"arm64"
],
@ -1281,9 +1282,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
"integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
"cpu": [
"ppc64"
],
@ -1297,9 +1298,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
"integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
"cpu": [
"s390x"
],
@ -1313,9 +1314,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
"integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
"cpu": [
"x64"
],
@ -1329,9 +1330,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
"integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
"cpu": [
"x64"
],
@ -1345,9 +1346,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
"integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
"cpu": [
"arm64"
],
@ -1361,27 +1362,27 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
"integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "1.10.0",
"@emnapi/runtime": "1.10.0",
"@napi-rs/wasm-runtime": "^1.1.4"
"@emnapi/core": "1.11.1",
"@emnapi/runtime": "1.11.1",
"@napi-rs/wasm-runtime": "^1.1.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
"integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
"cpu": [
"arm64"
],
@ -1395,9 +1396,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
"integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
"cpu": [
"x64"
],
@ -1436,15 +1437,31 @@
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
"integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
"integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/jsesc": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
@ -1507,39 +1524,39 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.38.tgz",
"integrity": "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.39.tgz",
"integrity": "sha512-16KBTEXAJCpDr0mwlw+AZyhu8iyC7R3S2vBwsI7QnWJU6X3WKc9VKeNEZpiMdZ569qWhz9574L3vV55qRL0Vtw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@vue/shared": "3.5.38",
"@vue/shared": "3.5.39",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.38.tgz",
"integrity": "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.39.tgz",
"integrity": "sha512-oQPigALqYbNxTNPvNgSOe+czwVExfbVF02lz8jP0S3AXJiu3jxYDygNUiqSep4ezzW8XgnubqH63My2A7JR/vg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.38",
"@vue/shared": "3.5.38"
"@vue/compiler-core": "3.5.39",
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.38.tgz",
"integrity": "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.39.tgz",
"integrity": "sha512-d0ki86iOyN8LoZPBmk5SJWNwHP19CnDDCfuo//+2WJa2g5Ke0Jay983PIBIcSSzldC68I8DrD5GrHV3OSDfodg==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.7",
"@vue/compiler-core": "3.5.38",
"@vue/compiler-dom": "3.5.38",
"@vue/compiler-ssr": "3.5.38",
"@vue/shared": "3.5.38",
"@vue/compiler-core": "3.5.39",
"@vue/compiler-dom": "3.5.39",
"@vue/compiler-ssr": "3.5.39",
"@vue/shared": "3.5.39",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.15",
@ -1547,13 +1564,13 @@
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.38.tgz",
"integrity": "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.39.tgz",
"integrity": "sha512-Ce7/wvwMHai74bdszfXExdazFigYnlF9zgCmEQUcM1j0fOymlouZ7XilTYNo8oUjhlnjYOZbGrcYKuqjz89Ucw==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.38",
"@vue/shared": "3.5.38"
"@vue/compiler-dom": "3.5.39",
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/devtools-api": {
@ -1581,53 +1598,53 @@
"license": "MIT"
},
"node_modules/@vue/reactivity": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.38.tgz",
"integrity": "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.39.tgz",
"integrity": "sha512-TpsuBJ9gGlZa5d23XcM2y8EXanz9dZeVDQBXRwzy46ItgvM+rWpzs+UVM0wcRLxGvcav0HE5jz2gNL53xlRAog==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.38"
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.38.tgz",
"integrity": "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.39.tgz",
"integrity": "sha512-9GLtNyRvPAUMbX+7ono0RC2j0guo2LXVi8LvcmAooImACUKm0oFf0jjwbX8/H0AE/t1nxhAkn8RSl9PMCzzxZw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.38",
"@vue/shared": "3.5.38"
"@vue/reactivity": "3.5.39",
"@vue/shared": "3.5.39"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.38.tgz",
"integrity": "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.39.tgz",
"integrity": "sha512-7Y6aAGboKcXAZ3ECuUy7RrS5yy2r47dhTp2SKaJmYxjopImaVFaNa5Ne66NwGovsrxVAl5S5rwc7m22UG7Lmww==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.38",
"@vue/runtime-core": "3.5.38",
"@vue/shared": "3.5.38",
"@vue/reactivity": "3.5.39",
"@vue/runtime-core": "3.5.39",
"@vue/shared": "3.5.39",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.38.tgz",
"integrity": "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw==",
"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==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.38",
"@vue/shared": "3.5.38"
"@vue/compiler-ssr": "3.5.39",
"@vue/shared": "3.5.39"
},
"peerDependencies": {
"vue": "3.5.38"
"vue": "3.5.39"
}
},
"node_modules/@vue/shared": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.38.tgz",
"integrity": "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.39.tgz",
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==",
"license": "MIT"
},
"node_modules/@xterm/addon-fit": {
@ -1949,6 +1966,13 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"dev": true,
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
@ -2138,9 +2162,9 @@
"license": "MIT"
},
"node_modules/cosmiconfig": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz",
"integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==",
"version": "9.0.2",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.2.tgz",
"integrity": "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -2990,6 +3014,51 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"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==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"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"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"peerDependencies": {
"@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0",
"@typescript-eslint/parser": "^7.0.0 || ^8.0.0",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"vue-eslint-parser": "^10.3.0"
},
"peerDependenciesMeta": {
"@stylistic/eslint-plugin": {
"optional": true
},
"@typescript-eslint/parser": {
"optional": true
}
}
},
"node_modules/eslint-plugin-vue/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/eslint-scope": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz",
@ -4897,6 +4966,19 @@
"node": ">=0.10.0"
}
},
"node_modules/nth-check": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz",
"integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==",
"dev": true,
"license": "BSD-2-Clause",
"dependencies": {
"boolbase": "^1.0.0"
},
"funding": {
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -5186,19 +5268,19 @@
"license": "ISC"
},
"node_modules/picocrank": {
"version": "1.17.0",
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.17.0.tgz",
"integrity": "sha512-EIUSI26elt6ulloN74CT2sX5tBxS2wjdV4A+eXHYNtHbH0hM2iKkYzpKKWOEr+yUTfrvjah6VuqieedFw/9R0w==",
"version": "1.18.2",
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.18.2.tgz",
"integrity": "sha512-YR1Y00KnEPjH6OCrijSxzTHoyHLhKrsPhkA7ubOkbvcUuD7wtVKbRxfPkjAkA838dGymjoG//iFca2oLnNT5tg==",
"license": "ISC",
"dependencies": {
"@hugeicons/core-free-icons": "^4.1.1",
"@hugeicons/vue": "^1.0.5",
"@vitejs/plugin-vue": "^6.0.6",
"@hugeicons/core-free-icons": "^4.2.2",
"@hugeicons/vue": "^1.0.7",
"@vitejs/plugin-vue": "^6.0.7",
"femtocrank": "^2.5.0",
"unplugin-vue-components": "^32.0.0",
"vite": "^8.0.10",
"vue": "^3.5.33",
"vue-router": "^5.0.6"
"unplugin-vue-components": "^32.1.0",
"vite": "^8.1.2",
"vue": "^3.5.39",
"vue-router": "^5.1.0"
}
},
"node_modules/picomatch": {
@ -5318,9 +5400,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"funding": [
{
"type": "opencollective",
@ -5373,9 +5455,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
"version": "7.1.4",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz",
"integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -5620,12 +5702,12 @@
}
},
"node_modules/rolldown": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
"integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.133.0",
"@oxc-project/types": "=0.139.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@ -5635,21 +5717,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.3",
"@rolldown/binding-darwin-arm64": "1.0.3",
"@rolldown/binding-darwin-x64": "1.0.3",
"@rolldown/binding-freebsd-x64": "1.0.3",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
"@rolldown/binding-linux-arm64-musl": "1.0.3",
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
"@rolldown/binding-linux-x64-gnu": "1.0.3",
"@rolldown/binding-linux-x64-musl": "1.0.3",
"@rolldown/binding-openharmony-arm64": "1.0.3",
"@rolldown/binding-wasm32-wasi": "1.0.3",
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
"@rolldown/binding-win32-x64-msvc": "1.0.3"
"@rolldown/binding-android-arm64": "1.1.5",
"@rolldown/binding-darwin-arm64": "1.1.5",
"@rolldown/binding-darwin-x64": "1.1.5",
"@rolldown/binding-freebsd-x64": "1.1.5",
"@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
"@rolldown/binding-linux-arm64-gnu": "1.1.5",
"@rolldown/binding-linux-arm64-musl": "1.1.5",
"@rolldown/binding-linux-ppc64-gnu": "1.1.5",
"@rolldown/binding-linux-s390x-gnu": "1.1.5",
"@rolldown/binding-linux-x64-gnu": "1.1.5",
"@rolldown/binding-linux-x64-musl": "1.1.5",
"@rolldown/binding-openharmony-arm64": "1.1.5",
"@rolldown/binding-wasm32-wasi": "1.1.5",
"@rolldown/binding-win32-arm64-msvc": "1.1.5",
"@rolldown/binding-win32-x64-msvc": "1.1.5"
}
},
"node_modules/run-parallel": {
@ -6151,9 +6233,9 @@
}
},
"node_modules/stylelint": {
"version": "17.13.0",
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.13.0.tgz",
"integrity": "sha512-G1WYzMerp7ihOaIe9VJCHLt12MoAD2QLf1AFerYP37+BCRBUK5UCpq8e/mN+zCIaJPKQcaxhE4WlPmqdiOx/gw==",
"version": "17.14.0",
"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.14.0.tgz",
"integrity": "sha512-8xkHPpdqYryeIsOgfsYTmr6cIeC4nLYWk5S8BPxpodq8mIuepggkMljsHewWfuAjj/+qpRKou2QerhjMH3iasg==",
"dev": true,
"funding": [
{
@ -6169,13 +6251,13 @@
"dependencies": {
"@csstools/css-calc": "^3.2.1",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-syntax-patches-for-csstree": "^1.1.4",
"@csstools/css-syntax-patches-for-csstree": "^1.1.5",
"@csstools/css-tokenizer": "^4.0.0",
"@csstools/media-query-list-parser": "^5.0.0",
"@csstools/selector-resolve-nested": "^4.0.0",
"@csstools/selector-specificity": "^6.0.0",
"colord": "^2.9.3",
"cosmiconfig": "^9.0.1",
"cosmiconfig": "^9.0.2",
"css-functions-list": "^3.3.3",
"css-tree": "^3.2.1",
"debug": "^4.4.3",
@ -6195,7 +6277,7 @@
"picocolors": "^1.1.1",
"postcss": "^8.5.15",
"postcss-safe-parser": "^7.0.1",
"postcss-selector-parser": "^7.1.1",
"postcss-selector-parser": "^7.1.4",
"postcss-value-parser": "^4.2.0",
"string-width": "^8.2.1",
"supports-hyperlinks": "^4.4.0",
@ -6790,15 +6872,15 @@
}
},
"node_modules/vite": {
"version": "8.0.16",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
"version": "8.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.15",
"rolldown": "1.0.3",
"postcss": "^8.5.16",
"rolldown": "~1.1.3",
"tinyglobby": "^0.2.17"
},
"bin": {
@ -6815,7 +6897,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.1.18",
"@vitejs/devtools": "^0.3.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
@ -6879,16 +6961,16 @@
}
},
"node_modules/vue": {
"version": "3.5.38",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.38.tgz",
"integrity": "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A==",
"version": "3.5.39",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.39.tgz",
"integrity": "sha512-xmZCYabFGcirU8r0fTuvl/LICc1OU620rnqepaJDL/a141ZigkG7AyaxQLdqJ02ZRYzWe6YPaDHeQx7MfknQfA==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.38",
"@vue/compiler-sfc": "3.5.38",
"@vue/runtime-dom": "3.5.38",
"@vue/server-renderer": "3.5.38",
"@vue/shared": "3.5.38"
"@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"
},
"peerDependencies": {
"typescript": "*"
@ -6899,6 +6981,98 @@
}
}
},
"node_modules/vue-eslint-parser": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.1.tgz",
"integrity": "sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"debug": "^4.4.0",
"eslint-scope": "^8.2.0 || ^9.0.0",
"eslint-visitor-keys": "^4.2.0 || ^5.0.0",
"espree": "^10.3.0 || ^11.0.0",
"esquery": "^1.6.0",
"semver": "^7.6.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://github.com/sponsors/mysticatea"
},
"peerDependencies": {
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/vue-eslint-parser/node_modules/eslint-scope": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"@types/esrecurse": "^4.3.1",
"@types/estree": "^1.0.8",
"esrecurse": "^4.3.0",
"estraverse": "^5.2.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vue-eslint-parser/node_modules/espree": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
"integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
"dev": true,
"license": "BSD-2-Clause",
"peer": true,
"dependencies": {
"acorn": "^8.16.0",
"acorn-jsx": "^5.3.2",
"eslint-visitor-keys": "^5.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"node_modules/vue-eslint-parser/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"dev": true,
"license": "ISC",
"peer": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/vue-i18n": {
"version": "11.4.6",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.6.tgz",
@ -7145,6 +7319,16 @@
"node": ">=8"
}
},
"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==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",

View File

@ -5,8 +5,9 @@
"repository": "https://github.com/OliveTin/OliveTin",
"source": "index.html",
"devDependencies": {
"eslint-plugin-vue": "^10.9.2",
"process": "^0.11.10",
"stylelint": "^17.13.0",
"stylelint": "^17.14.0",
"stylelint-config-standard": "^40.0.0"
},
"scripts": {
@ -24,18 +25,18 @@
"dependencies": {
"@connectrpc/connect": "^2.1.2",
"@connectrpc/connect-web": "^2.1.2",
"@hugeicons/core-free-icons": "^4.2.1",
"@hugeicons/vue": "^1.0.6",
"@hugeicons/core-free-icons": "^4.2.2",
"@hugeicons/vue": "^1.0.7",
"@vitejs/plugin-vue": "^6.0.7",
"@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.17.0",
"picocrank": "^1.18.2",
"standard": "^17.1.2",
"unplugin-vue-components": "^32.1.0",
"vite": "^8.0.16",
"vue": "^3.5.38",
"vite": "^8.1.3",
"vue": "^3.5.39",
"vue-i18n": "^11.4.6",
"vue-router": "^5.1.0"
},

View File

@ -1,36 +1,73 @@
<template>
<div :id="`actionButton-${bindingId}`" role="none" class="action-button" @contextmenu.prevent="openActionDetails">
<span
v-if="showExecutionIndicator"
class="execution-indicator"
:class="executionIndicatorClass"
:title="executionIndicatorTitle"
aria-hidden="true"
></span>
<button :id="`actionButtonInner-${bindingId}`" :title="title" :disabled="!canExec || isDisabled"
:class="combinedClasses" @click="handleClick">
<div
:id="`actionButton-${bindingId}`"
role="none"
class="action-button"
@contextmenu.prevent="openActionDetails"
>
<span
v-if="showExecutionIndicator"
class="execution-indicator"
:class="executionIndicatorClass"
:title="executionIndicatorTitle"
aria-hidden="true"
/>
<button
:id="`actionButtonInner-${bindingId}`"
:title="title"
:disabled="!canExec || isDisabled"
:class="combinedClasses"
@click="handleClick"
>
<div
v-if="showNavigateOnStartIcons"
class="navigate-on-start-container"
>
<div
v-if="navigateOnStart == 'pop'"
class="navigate-on-start"
title="Opens a popup dialog on start"
>
<HugeiconsIcon :icon="ComputerTerminal01Icon" />
</div>
<div
v-if="navigateOnStart == 'arg'"
class="navigate-on-start"
title="Opens an argument form on start"
>
<HugeiconsIcon :icon="TypeCursorIcon" />
</div>
<div
v-if="navigateOnStart == 'hist'"
class="navigate-on-start"
title="Opens action execution history on start"
>
<HugeiconsIcon :icon="WorkHistoryIcon" />
</div>
<div
v-if="navigateOnStart == ''"
class="navigate-on-start"
title="Run in the background"
>
<HugeiconsIcon :icon="WorkoutRunIcon" />
</div>
</div>
<div v-if="showNavigateOnStartIcons" class="navigate-on-start-container">
<div v-if="navigateOnStart == 'pop'" class="navigate-on-start" title="Opens a popup dialog on start">
<HugeiconsIcon :icon="ComputerTerminal01Icon" />
</div>
<div v-if="navigateOnStart == 'arg'" class="navigate-on-start" title="Opens an argument form on start">
<HugeiconsIcon :icon="TypeCursorIcon" />
</div>
<div v-if="navigateOnStart == 'hist'" class="navigate-on-start" title="Opens action execution history on start">
<HugeiconsIcon :icon="WorkHistoryIcon" />
</div>
<div v-if="navigateOnStart == ''" class="navigate-on-start" title="Run in the background">
<HugeiconsIcon :icon="WorkoutRunIcon" />
</div>
</div>
<ActionIconGlyph class="icon" :glyph="actionGlyph" />
<span class="title" aria-live="polite">{{ displayTitle }}
</span>
<span v-if="rateLimitMessage" class="rate-limit-message">{{ rateLimitMessage }}</span>
</button>
</div>
<ActionIconGlyph
class="icon"
:glyph="actionGlyph"
/>
<span
class="title"
aria-live="polite"
>{{ displayTitle }}
</span>
<span
v-if="rateLimitMessage"
class="rate-limit-message"
>{{ rateLimitMessage }}</span>
</button>
</div>
</template>
<script setup>
@ -59,18 +96,18 @@ const navigateOnStart = ref('')
const props = defineProps({
actionData: {
type: Object,
required: true
type: Object,
required: true
},
cssClass: {
type: String,
required: false,
default: ''
type: String,
required: false,
default: ''
},
prefilledArguments: {
type: Object,
required: false,
default: () => ({})
type: Object,
required: false,
default: () => ({})
}
})
@ -84,7 +121,6 @@ const displayTitle = ref('')
// State
const isDisabled = ref(false)
const showArgumentForm = ref(false)
// Rate limiting
const rateLimitExpires = ref(0)
@ -99,7 +135,7 @@ const flashedTrackingIds = new Set()
// Show navigate on start icons - defaults to true if not set
const showNavigateOnStartIcons = computed(() => {
return window.initResponse?.showNavigateOnStartIcons ?? true
return window.initResponse?.showNavigateOnStartIcons ?? true
})
const actionGlyph = computed(() => props.actionData?.icon ?? '')
@ -107,63 +143,63 @@ const glyph = ref('')
// Combined classes including custom cssClass
const combinedClasses = computed(() => {
const classes = [...buttonClasses.value]
if (props.cssClass) {
classes.push(props.cssClass)
}
return classes
const classes = [...buttonClasses.value]
if (props.cssClass) {
classes.push(props.cssClass)
}
return classes
})
const hasRunningInstance = computed(() => {
const id = bindingId.value
return !!(id && bindingExecutionState[id]?.hasRunning)
const id = bindingId.value
return !!(id && bindingExecutionState[id]?.hasRunning)
})
const hasQueuedInstance = computed(() => {
const id = bindingId.value
return !!(id && bindingExecutionState[id]?.hasQueued)
const id = bindingId.value
return !!(id && bindingExecutionState[id]?.hasQueued)
})
const showExecutionIndicator = computed(() => {
return hasRunningInstance.value || hasQueuedInstance.value
return hasRunningInstance.value || hasQueuedInstance.value
})
const executionIndicatorClass = computed(() => {
if (hasRunningInstance.value) {
return 'execution-indicator-running'
}
if (hasQueuedInstance.value) {
return 'execution-indicator-queued'
}
return ''
if (hasRunningInstance.value) {
return 'execution-indicator-running'
}
if (hasQueuedInstance.value) {
return 'execution-indicator-queued'
}
return ''
})
const executionIndicatorTitle = computed(() => {
if (hasRunningInstance.value) {
return 'Running'
}
if (hasQueuedInstance.value) {
return 'Queued'
}
return ''
if (hasRunningInstance.value) {
return 'Running'
}
if (hasQueuedInstance.value) {
return 'Queued'
}
return ''
})
function consumeAndFlashPendingResult () {
const id = bindingId.value
if (!id) {
return
}
const id = bindingId.value
if (!id) {
return
}
const pending = consumePendingBindingFlash(id)
if (pending) {
onExecutionFinished(pending)
}
const pending = consumePendingBindingFlash(id)
if (pending) {
onExecutionFinished(pending)
}
}
// Timestamps
const updateIterationTimestamp = ref(0)
function constructFromJson(json) {
function constructFromJson (json) {
updateIterationTimestamp.value = 0
updateFromJson(json)
@ -174,11 +210,11 @@ function constructFromJson(json) {
popupOnStart.value = json.popupOnStart
if (popupOnStart.value.includes('execution-dialog')) {
navigateOnStart.value = 'pop'
navigateOnStart.value = 'pop'
} else if (popupOnStart.value === 'history') {
navigateOnStart.value = 'hist'
navigateOnStart.value = 'hist'
} else if (needsArgumentForm(props.actionData)) {
navigateOnStart.value = 'arg'
navigateOnStart.value = 'arg'
}
isDisabled.value = !json.canExec
@ -186,111 +222,111 @@ function constructFromJson(json) {
glyph.value = json.icon ?? ''
// Initialize rate limit from action data (parse datetime string)
if (json.datetimeRateLimitExpires) {
const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
rateLimitExpires.value = date.getTime() / 1000
const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
rateLimitExpires.value = date.getTime() / 1000
} else {
rateLimitExpires.value = 0
rateLimitExpires.value = 0
}
// Also initialize the store so the watch picks it up
if (bindingId.value) {
rateLimits[bindingId.value] = rateLimitExpires.value
setBindingExecutionState(
rateLimits[bindingId.value] = rateLimitExpires.value
setBindingExecutionState(
bindingId.value,
!!json.hasRunningInstance,
!!json.hasQueuedInstance
)
)
}
updateRateLimitStatus()
}
function updateFromJson(json) {
function updateFromJson (json) {
// Fields that should not be updated
// title - as the callback URL relies on it
// Update rate limiting if changed (parse datetime string)
if (json.datetimeRateLimitExpires) {
const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
rateLimitExpires.value = date.getTime() / 1000
updateRateLimitStatus()
const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
rateLimitExpires.value = date.getTime() / 1000
updateRateLimitStatus()
} else if (json.datetimeRateLimitExpires === '') {
// Explicitly clear if empty string
rateLimitExpires.value = 0
updateRateLimitStatus()
// Explicitly clear if empty string
rateLimitExpires.value = 0
updateRateLimitStatus()
}
}
function updateRateLimitStatus() {
function updateRateLimitStatus () {
if (rateLimitExpires.value === 0) {
isRateLimited.value = false
rateLimitMessage.value = ''
if (rateLimitInterval.value) {
isRateLimited.value = false
rateLimitMessage.value = ''
if (rateLimitInterval.value) {
clearInterval(rateLimitInterval.value)
rateLimitInterval.value = null
}
return
}
return
}
const now = Math.floor(Date.now() / 1000)
const expires = rateLimitExpires.value
if (now >= expires) {
// Rate limit has expired
isRateLimited.value = false
rateLimitMessage.value = ''
rateLimitExpires.value = 0
if (rateLimitInterval.value) {
// Rate limit has expired
isRateLimited.value = false
rateLimitMessage.value = ''
rateLimitExpires.value = 0
if (rateLimitInterval.value) {
clearInterval(rateLimitInterval.value)
rateLimitInterval.value = null
}
}
} else {
// Still rate limited
isRateLimited.value = true
const secondsRemaining = expires - now
rateLimitMessage.value = `Rate limited, available in ${secondsRemaining} second${secondsRemaining !== 1 ? 's' : ''}`
// Still rate limited
isRateLimited.value = true
const secondsRemaining = expires - now
rateLimitMessage.value = `Rate limited, available in ${secondsRemaining} second${secondsRemaining !== 1 ? 's' : ''}`
// Set up interval to update every second
if (!rateLimitInterval.value) {
// Set up interval to update every second
if (!rateLimitInterval.value) {
rateLimitInterval.value = setInterval(() => {
updateRateLimitStatus()
updateRateLimitStatus()
}, 1000)
}
}
}
}
function openActionDetails() {
function openActionDetails () {
const id = props.actionData?.bindingId
if (!id) {
return
return
}
router.push(`/action/${id}`)
}
async function handleClick() {
async function handleClick () {
if (popupOnStart.value === 'history') {
openActionDetails()
return
openActionDetails()
return
}
if (needsArgumentForm(props.actionData)) {
const bindingId = props.actionData.bindingId
const prefilled = props.prefilledArguments || {}
if (Object.keys(prefilled).length > 0) {
const bindingId = props.actionData.bindingId
const prefilled = props.prefilledArguments || {}
if (Object.keys(prefilled).length > 0) {
router.push({
path: `/actionBinding/${bindingId}/argumentForm`,
state: { prefilledArguments: prefilled }
path: `/actionBinding/${bindingId}/argumentForm`,
state: { prefilledArguments: prefilled }
})
} else {
} else {
router.push(`/actionBinding/${bindingId}/argumentForm`)
}
}
} else {
await startAction()
await startAction()
}
}
function getUniqueId() {
function getUniqueId () {
if (window.isSecureContext) {
return window.crypto.randomUUID()
return window.crypto.randomUUID()
} else {
return Date.now().toString()
return Date.now().toString()
}
}
@ -323,122 +359,122 @@ async function pollExecutionUntilDone (trackingId) {
}
}
async function startAction(actionArgs) {
async function startAction (actionArgs) {
buttonClasses.value = [] // Removes old animation classes
if (actionArgs === undefined) {
actionArgs = []
actionArgs = []
}
// UUIDs are create client side, so that we can setup a "execution-button"
// to track the execution before we send the request to the server.
const startActionArgs = {
bindingId: props.actionData.bindingId,
arguments: actionArgs,
uniqueTrackingId: getUniqueId()
bindingId: props.actionData.bindingId,
arguments: actionArgs,
uniqueTrackingId: getUniqueId()
}
console.log('Watching buttonResults for', startActionArgs.uniqueTrackingId)
watch(
() => buttonResults[startActionArgs.uniqueTrackingId],
(newResult, oldResult) => {
() => buttonResults[startActionArgs.uniqueTrackingId],
(newResult, oldResult) => {
onLogEntryChanged(newResult)
}
}
)
requestReconnectNow()
try {
const response = await window.client.startAction(startActionArgs)
const trackingId = response.executionTrackingId || startActionArgs.uniqueTrackingId
const response = await window.client.startAction(startActionArgs)
const trackingId = response.executionTrackingId || startActionArgs.uniqueTrackingId
if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
router.push(`/logs/${trackingId}`)
}
}
if (!connectionState.connected) {
if (!connectionState.connected) {
await pollExecutionUntilDone(trackingId)
}
}
} catch (err) {
console.error('Failed to start action:', err)
console.error('Failed to start action:', err)
}
}
function onLogEntryChanged(logEntry) {
function onLogEntryChanged (logEntry) {
if (logEntry.executionFinished) {
onExecutionFinished(logEntry)
onExecutionFinished(logEntry)
} else if (logEntry.queued && !logEntry.executionStarted) {
onExecutionQueued(logEntry)
onExecutionQueued(logEntry)
} else {
onExecutionStarted(logEntry)
onExecutionStarted(logEntry)
}
}
function onExecutionQueued(_logEntry) {
function onExecutionQueued (_logEntry) {
isDisabled.value = true
updateDom('action-queued', '[Queued]')
}
function onExecutionStarted(logEntry) {
function onExecutionStarted (logEntry) {
if (
popupOnStart.value &&
popupOnStart.value &&
popupOnStart.value.includes('execution-dialog') &&
!shouldSuppressPopupOnStartNavigation(router)
) {
router.push(`/logs/${logEntry.executionTrackingId}`)
router.push(`/logs/${logEntry.executionTrackingId}`)
}
isDisabled.value = true
updateDom(null, title.value)
}
function onExecutionFinished(logEntry) {
function onExecutionFinished (logEntry) {
const trackingId = logEntry.executionTrackingId
if (trackingId) {
if (flashedTrackingIds.has(trackingId)) {
return
}
flashedTrackingIds.add(trackingId)
if (flashedTrackingIds.has(trackingId)) {
return
}
flashedTrackingIds.add(trackingId)
}
// Local no-arg watches and the binding-scoped pending flash can both
// observe the same finished execution; consume so only one path flashes.
if (bindingId.value) {
consumePendingBindingFlash(bindingId.value)
consumePendingBindingFlash(bindingId.value)
}
if (logEntry.timedOut) {
renderExecutionResult('action-timeout', 'Timed out')
renderExecutionResult('action-timeout', 'Timed out')
} else if (logEntry.blocked) {
renderExecutionResult('action-blocked', 'Blocked!')
renderExecutionResult('action-blocked', 'Blocked!')
} else if (logEntry.exitCode !== 0) {
renderExecutionResult('action-nonzero-exit', 'Exit code ' + logEntry.exitCode)
renderExecutionResult('action-nonzero-exit', 'Exit code ' + logEntry.exitCode)
} else {
renderExecutionResult('action-success', 'Success!')
renderExecutionResult('action-success', 'Success!')
}
}
function renderExecutionResult(resultCssClass, temporaryStatusMessage) {
function renderExecutionResult (resultCssClass, temporaryStatusMessage) {
updateDom(resultCssClass, '[' + temporaryStatusMessage + ']')
onExecStatusChanged()
}
function updateDom(resultCssClass, newTitle) {
function updateDom (resultCssClass, newTitle) {
if (resultCssClass == null) {
buttonClasses.value = []
buttonClasses.value = []
} else {
buttonClasses.value = [resultCssClass]
buttonClasses.value = [resultCssClass]
}
displayTitle.value = newTitle
}
function onExecStatusChanged() {
function onExecStatusChanged () {
isDisabled.value = false
setTimeout(() => {
updateDom(null, title.value)
updateDom(null, title.value)
}, 2000)
}
@ -448,47 +484,47 @@ onMounted(() => {
// Watch the central rate limit store for updates to this button's bindingId
// Watch the entire rateLimits object to ensure reactivity with dynamic keys
watch(
rateLimits,
() => {
rateLimits,
() => {
const id = bindingId.value
if (id && rateLimits[id] !== undefined) {
const newExpires = rateLimits[id]
if (newExpires !== rateLimitExpires.value) {
const newExpires = rateLimits[id]
if (newExpires !== rateLimitExpires.value) {
rateLimitExpires.value = newExpires
updateRateLimitStatus()
}
}
}
},
{ deep: true }
},
{ deep: true }
)
// Binding-scoped flash survives argument-form navigation/remount (#920).
watch(
() => pendingBindingFlash[bindingId.value],
(pending) => {
() => pendingBindingFlash[bindingId.value],
(pending) => {
if (pending) {
consumeAndFlashPendingResult()
consumeAndFlashPendingResult()
}
},
{ immediate: true }
},
{ immediate: true }
)
})
onUnmounted(() => {
isComponentMounted.value = false
if (rateLimitInterval.value) {
clearInterval(rateLimitInterval.value)
rateLimitInterval.value = null
clearInterval(rateLimitInterval.value)
rateLimitInterval.value = null
}
})
watch(
() => props.actionData,
(newData) => {
updateFromJson(newData)
if (newData?.icon !== undefined) {
updateFromJson(newData)
if (newData?.icon !== undefined) {
glyph.value = newData.icon ?? ''
}
}
},
{ deep: true }
)

View File

@ -1,126 +1,218 @@
<template>
<Header :title="pageTitle" :logoUrl="logoUrl" @toggleSidebar="toggleSidebar" :sidebarEnabled="sidebarEnabled" :topBarEnabled="topbarEnabled" :navigation="navigation">
<template #toolbar>
<div id="banner" v-if="bannerMessage" :style="bannerCss">
<p>{{ bannerMessage }}</p>
</div>
</template>
<Header
:title="pageTitle"
:logo-url="logoUrl"
:sidebar-enabled="sidebarEnabled"
:top-bar-enabled="topbarEnabled"
:navigation="navigation"
@toggle-sidebar="toggleSidebar"
>
<template #toolbar>
<div
v-if="bannerMessage"
id="banner"
:style="bannerCss"
>
<p>{{ bannerMessage }}</p>
</div>
</template>
<template #user-info>
<ConnectionBanner />
<div class="flex-row user-info" style="gap: .5em;">
<span id="link-login" v-if="!isLoggedIn && showLoginLink"><router-link to="/login">{{ t('login-button') }}</router-link></span>
<router-link v-else to="/user" class="user-link" v-if="isLoggedIn">
<span id="username-text">{{ username }}</span>
</router-link>
<HugeiconsIcon :icon="UserCircle02Icon" width = "1.5em" height = "1.5em" v-if="isLoggedIn" />
</div>
<template #user-info>
<ConnectionBanner />
<div
class="flex-row user-info"
style="gap: .5em;"
>
<span
v-if="!isLoggedIn && showLoginLink"
id="link-login"
><router-link to="/login">{{ t('login-button') }}</router-link></span>
<router-link
v-else-if="isLoggedIn"
to="/user"
class="user-link"
>
<span id="username-text">{{ username }}</span>
</router-link>
<HugeiconsIcon
v-if="isLoggedIn"
:icon="UserCircle02Icon"
width="1.5em"
height="1.5em"
/>
</div>
</template>
</Header>
</template>
</Header>
<div id="layout">
<Navigation ref="navigation">
<Sidebar
v-if="sidebarEnabled && showNavigation"
id="mainnav"
ref="sidebar"
/>
</Navigation>
<div id="layout">
<Navigation ref="navigation">
<Sidebar ref="sidebar" id = "mainnav" v-if="sidebarEnabled && showNavigation"/>
</Navigation>
<div
id="content"
:initial-martial-complete="hasLoaded"
>
<main title="Main content">
<router-view :key="$route.fullPath" />
</main>
<div id="content" initial-martial-complete="{{ hasLoaded }}">
<main title="Main content">
<router-view :key="$route.fullPath" />
</main>
<footer
v-if="showFooter"
title="footer"
>
<p>
<img
title="application icon"
:src="logoUrl"
alt="OliveTin logo"
style="height: 1em;"
class="logo"
>
OliveTin <span v-if="showVersionNumber">{{ currentVersion }}</span>
</p>
<p>
<span>
<a
href="https://docs.olivetin.app"
target="_new"
>{{ t('docs') }}</a>
</span>
<footer title="footer" v-if="showFooter">
<p>
<img title="application icon" :src="logoUrl" alt="OliveTin logo" style="height: 1em;" class="logo" />
OliveTin <span v-if="showVersionNumber">{{ currentVersion }}</span>
</p>
<p>
<span>
<a href="https://docs.olivetin.app" target="_new">{{ t('docs') }}</a>
</span>
<span>
<a
href="https://github.com/OliveTin/OliveTin/issues/new/choose"
target="_new"
>{{ t('raise-issue') }}</a>
</span>
<span>
<a href="https://github.com/OliveTin/OliveTin/issues/new/choose" target="_new">{{ t('raise-issue') }}</a>
</span>
<span>
<a
href="#"
@click.prevent="openLanguageDialog"
>{{ currentLanguageName }}</a>
</span>
<span>
<a href="#" @click.prevent="openLanguageDialog">{{ currentLanguageName }}</a>
</span>
<span v-if="availableThemes.length > 1">
<a href="#" @click.prevent="openThemeDialog">{{ currentThemeName }}</a>
</span>
</p>
<p v-if="showVersionNumber">
<a id="available-version" href="http://olivetin.app" target="_blank" hidden>?</a>
</p>
</footer>
</div>
<span v-if="availableThemes.length > 1">
<a
href="#"
@click.prevent="openThemeDialog"
>{{ currentThemeName }}</a>
</span>
</p>
<p v-if="showVersionNumber">
<a
id="available-version"
href="http://olivetin.app"
target="_blank"
hidden
>?</a>
</p>
</footer>
</div>
</div>
<dialog ref="languageDialog" class="language-dialog" @click="handleLanguageDialogClick">
<div class="dialog-content" @click.stop>
<h2>{{ t('language-dialog.title') }}</h2>
<select v-model="selectedLanguage" @change="changeLanguage" class="language-select">
<option v-for="(name, code) in availableLanguages" :key="code" :value="code">
{{ code === 'auto' ? name : `${name} (${code})` }}
</option>
</select>
<p class="browser-languages">
{{ t('language-dialog.browser-languages') }}:
<span v-if="browserLanguages.length > 0">{{ browserLanguages.join(', ') }}</span>
<span v-else>{{ t('language-dialog.not-available') }}</span>
</p>
<div class="dialog-buttons">
<button @click="closeLanguageDialog">{{ t('language-dialog.close') }}</button>
</div>
</div>
</dialog>
<dialog
ref="languageDialog"
class="language-dialog"
@click="handleLanguageDialogClick"
>
<div
class="dialog-content"
@click.stop
>
<h2>{{ t('language-dialog.title') }}</h2>
<select
v-model="selectedLanguage"
class="language-select"
@change="changeLanguage"
>
<option
v-for="(name, code) in availableLanguages"
:key="code"
:value="code"
>
{{ code === 'auto' ? name : `${name} (${code})` }}
</option>
</select>
<p class="browser-languages">
{{ t('language-dialog.browser-languages') }}:
<span v-if="browserLanguages.length > 0">{{ browserLanguages.join(', ') }}</span>
<span v-else>{{ t('language-dialog.not-available') }}</span>
</p>
<div class="dialog-buttons">
<button @click="closeLanguageDialog">
{{ t('language-dialog.close') }}
</button>
</div>
</div>
</dialog>
<dialog ref="themeDialog" class="theme-dialog" @click="handleThemeDialogClick">
<div class="dialog-content" @click.stop>
<h2>{{ t('theme-dialog.title') }}</h2>
<select v-model="selectedTheme" @change="changeTheme" class="language-select">
<option value="">{{ t('theme-dialog.default') }}</option>
<option v-for="theme in availableThemes" :key="theme" :value="theme">
{{ theme }}
</option>
</select>
<div class="dialog-buttons">
<button @click="closeThemeDialog">{{ t('theme-dialog.close') }}</button>
</div>
</div>
</dialog>
<dialog
ref="themeDialog"
class="theme-dialog"
@click="handleThemeDialogClick"
>
<div
class="dialog-content"
@click.stop
>
<h2>{{ t('theme-dialog.title') }}</h2>
<select
v-model="selectedTheme"
class="language-select"
@change="changeTheme"
>
<option value="">
{{ t('theme-dialog.default') }}
</option>
<option
v-for="theme in availableThemes"
:key="theme"
:value="theme"
>
{{ theme }}
</option>
</select>
<div class="dialog-buttons">
<button @click="closeThemeDialog">
{{ t('theme-dialog.close') }}
</button>
</div>
</div>
</dialog>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import Sidebar from 'picocrank/vue/components/Sidebar.vue';
import Navigation from 'picocrank/vue/components/Navigation.vue';
import Header from 'picocrank/vue/components/Header.vue';
import ConnectionBanner from './components/ConnectionBanner.vue';
import { connectEventStreamIfNeeded } from '../../js/websocket.js';
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import Sidebar from 'picocrank/vue/components/Sidebar.vue'
import Navigation from 'picocrank/vue/components/Navigation.vue'
import Header from 'picocrank/vue/components/Header.vue'
import ConnectionBanner from './components/ConnectionBanner.vue'
import { connectEventStreamIfNeeded } from '../../js/websocket.js'
import { HugeiconsIcon } from '@hugeicons/vue'
import { Menu01Icon } from '@hugeicons/core-free-icons'
import { UserCircle02Icon } from '@hugeicons/core-free-icons'
import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
import logoUrl from '../../OliveTinLogo.png';
import { useI18n } from 'vue-i18n';
import combinedTranslations from '../../../lang/combined_output.json';
const { t, locale } = useI18n();
import { UserCircle02Icon, DashboardSquare01Icon } from '@hugeicons/core-free-icons'
import logoUrl from '../../OliveTinLogo.png'
import { useI18n } from 'vue-i18n'
import combinedTranslations from '../../../lang/combined_output.json'
const { t } = useI18n()
const router = useRouter();
const router = useRouter()
const sidebar = ref(null);
const navigation = ref(null);
const username = ref('notset');
const isLoggedIn = ref(false);
const currentVersion = ref('?');
const pageTitle = ref('OliveTin');
const bannerMessage = ref('');
const bannerCss = ref('');
const hasLoaded = ref(false);
const sidebar = ref(null)
const navigation = ref(null)
const username = ref('notset')
const isLoggedIn = ref(false)
const currentVersion = ref('?')
const pageTitle = ref('OliveTin')
const bannerMessage = ref('')
const bannerCss = ref('')
const hasLoaded = ref(false)
const showFooter = ref(true)
const showNavigation = ref(true)
const showLogs = ref(true)
@ -144,279 +236,279 @@ const selectedTheme = ref(themePreference.value)
// Available languages with display names
const availableLanguages = {
'auto': 'Browser Language',
'en': 'English',
'de-DE': 'Deutsch',
'es-ES': 'Español',
'it-IT': 'Italiano',
'zh-Hans-CN': '简体中文'
auto: 'Browser Language',
en: 'English',
'de-DE': 'Deutsch',
'es-ES': 'Español',
'it-IT': 'Italiano',
'zh-Hans-CN': '简体中文'
}
// Computed property to get current language display name
const currentLanguageName = computed(() => {
if (languagePreference.value === 'auto') {
return availableLanguages['auto']
}
if (languagePreference.value === 'auto') {
return availableLanguages.auto
}
return availableLanguages[languagePreference.value] || languagePreference.value
return availableLanguages[languagePreference.value] || languagePreference.value
})
// Computed property to get current theme display name
const currentThemeName = computed(() => {
if (!themePreference.value || themePreference.value === '') {
return t('theme-dialog.default')
}
return themePreference.value
if (!themePreference.value || themePreference.value === '') {
return t('theme-dialog.default')
}
return themePreference.value
})
// Computed properties for navigation style
const topbarEnabled = computed(() => {
return sectionNavigationStyle.value === 'topbar'
return sectionNavigationStyle.value === 'topbar'
})
const sidebarEnabled = computed(() => {
return sectionNavigationStyle.value !== 'topbar' && showNavigation.value
return sectionNavigationStyle.value !== 'topbar' && showNavigation.value
})
function normalizeBrowserLanguage() {
const available = Object.keys(combinedTranslations.messages || {})
function normalizeBrowserLanguage () {
const available = Object.keys(combinedTranslations.messages || {})
if (navigator.languages && navigator.languages.length > 0) {
for (const candidate of navigator.languages) {
const lowerCandidate = candidate.toLowerCase()
if (navigator.languages && navigator.languages.length > 0) {
for (const candidate of navigator.languages) {
const lowerCandidate = candidate.toLowerCase()
// Try exact match (case-insensitive)
const exact = available.find(locale => locale.toLowerCase() === lowerCandidate)
if (exact) {
return exact
}
// Try exact match (case-insensitive)
const exact = available.find(locale => locale.toLowerCase() === lowerCandidate)
if (exact) {
return exact
}
// Try prefix match (e.g., "zh-CN" -> "zh-Hans-CN")
const prefix = available.find(locale => locale.toLowerCase().startsWith(lowerCandidate.split('-')[0] + '-'))
if (prefix) {
return prefix
}
}
// Try prefix match (e.g., "zh-CN" -> "zh-Hans-CN")
const prefix = available.find(locale => locale.toLowerCase().startsWith(lowerCandidate.split('-')[0] + '-'))
if (prefix) {
return prefix
}
}
}
return 'en'
return 'en'
}
function toggleSidebar() {
if (sidebar.value && showNavigation.value) {
sidebar.value.toggle()
}
function toggleSidebar () {
if (sidebar.value && showNavigation.value) {
sidebar.value.toggle()
}
}
function updateHeaderFromInit() {
if (!window.initResponse) {
return
}
function updateHeaderFromInit () {
if (!window.initResponse) {
return
}
username.value = window.initResponse.authenticatedUser
isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
currentVersion.value = window.initResponse.currentVersion
pageTitle.value = window.initResponse.pageTitle || 'OliveTin'
bannerMessage.value = window.initResponse.bannerMessage || ''
bannerCss.value = window.initResponse.bannerCss || ''
showFooter.value = window.initResponse.showFooter
showNavigation.value = window.initResponse.showNavigation
showLogs.value = window.initResponse.showLogList
showDiagnostics.value = window.initResponse.showDiagnostics
showVersionNumber.value = window.initResponse.effectivePolicy?.showVersionNumber ?? true
sectionNavigationStyle.value = window.initResponse.sectionNavigationStyle || 'sidebar'
availableThemes.value = window.initResponse.availableThemes || []
username.value = window.initResponse.authenticatedUser
isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
currentVersion.value = window.initResponse.currentVersion
pageTitle.value = window.initResponse.pageTitle || 'OliveTin'
bannerMessage.value = window.initResponse.bannerMessage || ''
bannerCss.value = window.initResponse.bannerCss || ''
showFooter.value = window.initResponse.showFooter
showNavigation.value = window.initResponse.showNavigation
showLogs.value = window.initResponse.showLogList
showDiagnostics.value = window.initResponse.showDiagnostics
showVersionNumber.value = window.initResponse.effectivePolicy?.showVersionNumber ?? true
sectionNavigationStyle.value = window.initResponse.sectionNavigationStyle || 'sidebar'
availableThemes.value = window.initResponse.availableThemes || []
if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
showLoginLink.value = false
}
if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
showLoginLink.value = false
}
applyStyleMods()
loadCustomJsIfEnabled()
applyStyleMods()
loadCustomJsIfEnabled()
renderNavigation()
applyTheme()
if (window.initResponse.loginRequired) {
connectEventStreamIfNeeded()
router.push('/login')
return
}
renderNavigation()
applyTheme()
if (window.initResponse.loginRequired) {
connectEventStreamIfNeeded()
router.push('/login')
return
}
connectEventStreamIfNeeded()
}
function renderNavigation() {
if (!navigation.value) {
return
}
function renderNavigation () {
if (!navigation.value) {
return
}
const rootDashboards = window.initResponse?.rootDashboards || []
const rootDashboards = window.initResponse?.rootDashboards || []
if (typeof navigation.value.clear === 'function') {
navigation.value.clear()
}
if (typeof navigation.value.clear === 'function') {
navigation.value.clear()
}
for (const rootDashboard of rootDashboards) {
navigation.value.addNavigationLink({
id: rootDashboard,
name: rootDashboard,
title: rootDashboard,
path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`,
icon: DashboardSquare01Icon,
})
}
for (const rootDashboard of rootDashboards) {
navigation.value.addNavigationLink({
id: rootDashboard,
name: rootDashboard,
title: rootDashboard,
path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`,
icon: DashboardSquare01Icon
})
}
navigation.value.addSeparator()
navigation.value.addRouterLink('Entities', t('nav.entities'))
navigation.value.addSeparator()
navigation.value.addRouterLink('Entities', t('nav.entities'))
if (showLogs.value) {
navigation.value.addRouterLink('Logs', t('nav.logs'))
}
if (showLogs.value) {
navigation.value.addRouterLink('Logs', t('nav.logs'))
}
if (showDiagnostics.value) {
navigation.value.addRouterLink('Diagnostics', t('nav.diagnostics'))
}
if (showDiagnostics.value) {
navigation.value.addRouterLink('Diagnostics', t('nav.diagnostics'))
}
}
function openLanguageDialog() {
selectedLanguage.value = languagePreference.value
function openLanguageDialog () {
selectedLanguage.value = languagePreference.value
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
browserLanguages.value = navigator.languages
} else {
browserLanguages.value = []
}
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
browserLanguages.value = navigator.languages
} else {
browserLanguages.value = []
}
if (languageDialog.value) {
languageDialog.value.showModal()
}
if (languageDialog.value) {
languageDialog.value.showModal()
}
}
function closeLanguageDialog() {
if (languageDialog.value) {
languageDialog.value.close()
}
function closeLanguageDialog () {
if (languageDialog.value) {
languageDialog.value.close()
}
}
function changeLanguage() {
if (!window.i18n || !selectedLanguage.value) {
return
}
function changeLanguage () {
if (!window.i18n || !selectedLanguage.value) {
return
}
if (selectedLanguage.value === 'auto') {
localStorage.removeItem('olivetin-language')
languagePreference.value = 'auto'
window.i18n.locale.value = normalizeBrowserLanguage()
} else {
window.i18n.locale.value = selectedLanguage.value
localStorage.setItem('olivetin-language', selectedLanguage.value)
languagePreference.value = selectedLanguage.value
}
if (selectedLanguage.value === 'auto') {
localStorage.removeItem('olivetin-language')
languagePreference.value = 'auto'
window.i18n.locale.value = normalizeBrowserLanguage()
} else {
window.i18n.locale.value = selectedLanguage.value
localStorage.setItem('olivetin-language', selectedLanguage.value)
languagePreference.value = selectedLanguage.value
}
// Update navigation with new translations
if (navigation.value) {
renderNavigation()
}
// Update navigation with new translations
if (navigation.value) {
renderNavigation()
}
closeLanguageDialog()
}
function handleLanguageDialogClick (event) {
// Close dialog when clicking on the backdrop
if (event.target === languageDialog.value) {
closeLanguageDialog()
}
}
function handleLanguageDialogClick(event) {
// Close dialog when clicking on the backdrop
if (event.target === languageDialog.value) {
closeLanguageDialog()
}
function openThemeDialog () {
selectedTheme.value = themePreference.value || ''
if (themeDialog.value) {
themeDialog.value.showModal()
}
}
function openThemeDialog() {
selectedTheme.value = themePreference.value || ''
if (themeDialog.value) {
themeDialog.value.showModal()
}
function closeThemeDialog () {
if (themeDialog.value) {
themeDialog.value.close()
}
}
function closeThemeDialog() {
if (themeDialog.value) {
themeDialog.value.close()
}
function changeTheme () {
if (!selectedTheme.value || selectedTheme.value === '') {
localStorage.removeItem('olivetin-theme')
themePreference.value = ''
} else {
localStorage.setItem('olivetin-theme', selectedTheme.value)
themePreference.value = selectedTheme.value
}
applyTheme()
closeThemeDialog()
}
function changeTheme() {
if (!selectedTheme.value || selectedTheme.value === '') {
localStorage.removeItem('olivetin-theme')
themePreference.value = ''
} else {
localStorage.setItem('olivetin-theme', selectedTheme.value)
themePreference.value = selectedTheme.value
}
function applyTheme () {
let themeStyle = document.getElementById('theme-style')
applyTheme()
if (!themeStyle) {
themeStyle = document.createElement('style')
themeStyle.id = 'theme-style'
themeStyle.type = 'text/css'
document.head.appendChild(themeStyle)
}
// Load theme into @layer theme so it takes precedence over @layer components
if (themePreference.value && themePreference.value !== '') {
themeStyle.textContent = `@import url('/custom-webui/themes/${themePreference.value}/theme.css') layer(theme);`
} else {
themeStyle.textContent = '@import url(\'/theme.css\') layer(theme);'
}
}
function loadCustomJsIfEnabled () {
if (!window.initResponse?.enableCustomJs || document.getElementById('olivetin-custom-js')) {
return
}
const script = document.createElement('script')
script.src = '/custom-webui/custom.js'
script.async = true
script.id = 'olivetin-custom-js'
document.head.appendChild(script)
}
function applyStyleMods () {
if (!window.initResponse || !window.initResponse.styleMods) {
return
}
for (const styleMod of window.initResponse.styleMods) {
if (styleMod) {
document.body.classList.add(styleMod)
}
}
}
function handleThemeDialogClick (event) {
if (event.target === themeDialog.value) {
closeThemeDialog()
}
function applyTheme() {
let themeStyle = document.getElementById('theme-style')
if (!themeStyle) {
themeStyle = document.createElement('style')
themeStyle.id = 'theme-style'
themeStyle.type = 'text/css'
document.head.appendChild(themeStyle)
}
// Load theme into @layer theme so it takes precedence over @layer components
if (themePreference.value && themePreference.value !== '') {
themeStyle.textContent = `@import url('/custom-webui/themes/${themePreference.value}/theme.css') layer(theme);`
} else {
themeStyle.textContent = `@import url('/theme.css') layer(theme);`
}
}
function loadCustomJsIfEnabled() {
if (!window.initResponse?.enableCustomJs || document.getElementById('olivetin-custom-js')) {
return
}
const script = document.createElement('script')
script.src = '/custom-webui/custom.js'
script.async = true
script.id = 'olivetin-custom-js'
document.head.appendChild(script)
}
function applyStyleMods() {
if (!window.initResponse || !window.initResponse.styleMods) {
return
}
for (const styleMod of window.initResponse.styleMods) {
if (styleMod) {
document.body.classList.add(styleMod)
}
}
}
function handleThemeDialogClick(event) {
if (event.target === themeDialog.value) {
closeThemeDialog()
}
}
}
window.updateHeaderFromInit = updateHeaderFromInit
onMounted(() => {
updateHeaderFromInit()
updateHeaderFromInit()
// Initialize selected language from stored preference
selectedLanguage.value = languagePreference.value
// Initialize selected language from stored preference
selectedLanguage.value = languagePreference.value
// Initialize selected theme from stored preference
selectedTheme.value = themePreference.value || ''
// Initialize selected theme from stored preference
selectedTheme.value = themePreference.value || ''
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
browserLanguages.value = navigator.languages
}
if (typeof navigator !== 'undefined' && Array.isArray(navigator.languages)) {
browserLanguages.value = navigator.languages
}
})
</script>

View File

@ -1,58 +1,112 @@
<template>
<section v-if="!dashboard && !initError" style = "text-align: center; padding: 2em;">
<HugeiconsIcon :icon="Loading03Icon" width="3em" height="3em" style="animation: spin 1s linear infinite;" />
<p>Loading dashboard...</p>
<p style="color: var(--fg2);">{{ loadingTime }}s</p>
<section
v-if="!dashboard && !initError"
style="text-align: center; padding: 2em;"
>
<HugeiconsIcon
:icon="Loading03Icon"
width="3em"
height="3em"
style="animation: spin 1s linear infinite;"
/>
<p>Loading dashboard...</p>
<p style="color: var(--fg2);">
{{ loadingTime }}s
</p>
</section>
<section
v-if="initError"
style="text-align: center; padding: 2em;"
class="bad"
>
<h2 style="color: var(--error);">
Initialization Failed
</h2>
<p>{{ initError }}</p>
<p style="color: var(--fg2);">
Please check your configuration and try again.
</p>
</section>
<template v-else-if="dashboard">
<section v-if="dashboard.contents.length == 0">
<div
v-if="isDirectory"
class="back-button-container"
>
<button
class="back-button"
@click="goBack"
>
<HugeiconsIcon
:icon="ArrowLeftIcon"
width="1.2em"
height="1.2em"
/>
<span>Back</span>
</button>
</div>
<h2>{{ dashboard.title }}</h2>
<p
style="text-align: center"
class="padding"
>
This dashboard is empty.
</p>
</section>
<section v-if="initError" style="text-align: center; padding: 2em;" class = "bad">
<h2 style="color: var(--error);">Initialization Failed</h2>
<p>{{ initError }}</p>
<p style="color: var(--fg2);">Please check your configuration and try again.</p>
<section
v-else
class="transparent"
>
<div
v-if="isDirectory"
class="back-button-container"
>
<button
class="back-button"
@click="goBack"
>
<HugeiconsIcon
:icon="ArrowLeftIcon"
width="1.2em"
height="1.2em"
/>
<span>Back</span>
</button>
</div>
<div
v-for="component in dashboard.contents"
:key="component.title"
class="dashboard-row"
>
<h2 v-if="dashboard.title != 'Default'">
<router-link
v-if="component.entityType && component.entityKey"
:to="{
name: 'EntityDetails',
params: {
entityType: component.entityType,
entityKey: component.entityKey
}
}"
class="entity-link"
>
{{ component.title }}
</router-link>
<span v-else>{{ component.title }}</span>
</h2>
<fieldset :class="component.cssClass">
<template
v-for="subcomponent in component.contents"
:key="subcomponent.title || subcomponent.type"
>
<DashboardComponent :component="subcomponent" />
</template>
</fieldset>
</div>
</section>
<template v-else-if="dashboard">
<section v-if="dashboard.contents.length == 0">
<div class="back-button-container" v-if="isDirectory">
<button @click="goBack" class="back-button">
<HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
<span>Back</span>
</button>
</div>
<h2>{{ dashboard.title }}</h2>
<p style = "text-align: center" class = "padding">This dashboard is empty.</p>
</section>
<section class="transparent" v-else>
<div class="back-button-container" v-if="isDirectory">
<button @click="goBack" class="back-button">
<HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
<span>Back</span>
</button>
</div>
<div class = "dashboard-row" v-for="component in dashboard.contents" :key="component.title">
<h2 v-if = "dashboard.title != 'Default'">
<router-link
v-if="component.entityType && component.entityKey"
:to="{
name: 'EntityDetails',
params: {
entityType: component.entityType,
entityKey: component.entityKey
}
}"
class="entity-link">
{{ component.title }}
</router-link>
<span v-else>{{ component.title }}</span>
</h2>
<fieldset :class="component.cssClass">
<template v-for="subcomponent in component.contents">
<DashboardComponent :component="subcomponent" />
</template>
</fieldset>
</div>
</section>
</template>
</template>
</template>
<script setup>
@ -63,18 +117,18 @@ import { HugeiconsIcon } from '@hugeicons/vue'
import { Loading03Icon, ArrowLeftIcon } from '@hugeicons/core-free-icons'
const props = defineProps({
title: {
type: String,
required: false
},
entityType: {
type: String,
required: false
},
entityKey: {
type: String,
required: false
}
title: {
type: String,
required: false
},
entityType: {
type: String,
required: false
},
entityKey: {
type: String,
required: false
}
})
const router = useRouter()
@ -86,166 +140,166 @@ let checkInitInterval = null
let dashboardRequestId = 0
const isDirectory = computed(() => {
if (!dashboard.value || !window.initResponse) {
return false
}
const rootDashboards = window.initResponse.rootDashboards || []
return !rootDashboards.includes(dashboard.value.title) && dashboard.value.title !== 'Actions'
if (!dashboard.value || !window.initResponse) {
return false
}
const rootDashboards = window.initResponse.rootDashboards || []
return !rootDashboards.includes(dashboard.value.title) && dashboard.value.title !== 'Actions'
})
function goBack() {
if (window.history.length > 1) {
router.back()
function goBack () {
if (window.history.length > 1) {
router.back()
} else {
const rootDashboards = window.initResponse?.rootDashboards || []
if (rootDashboards.length > 0) {
router.push({ name: 'Dashboard', params: { title: rootDashboards[0] } })
} else {
const rootDashboards = window.initResponse?.rootDashboards || []
if (rootDashboards.length > 0) {
router.push({ name: 'Dashboard', params: { title: rootDashboards[0] } })
} else {
router.push({ name: 'Actions' })
}
router.push({ name: 'Actions' })
}
}
}
async function getDashboard() {
const requestId = ++dashboardRequestId
let title = props.title
async function getDashboard () {
const requestId = ++dashboardRequestId
let title = props.title
// If no specific title was provided or it's the placeholder 'default',
// prefer the first configured root dashboard (e.g., "Test").
if ((!title || title === 'default') && window.initResponse.rootDashboards && window.initResponse.rootDashboards.length > 0) {
title = window.initResponse.rootDashboards[0]
// If no specific title was provided or it's the placeholder 'default',
// prefer the first configured root dashboard (e.g., "Test").
if ((!title || title === 'default') && window.initResponse.rootDashboards && window.initResponse.rootDashboards.length > 0) {
title = window.initResponse.rootDashboards[0]
}
try {
const request = {
title
}
try {
const request = {
title: title,
}
if (props.entityType && props.entityKey) {
request.entityType = props.entityType
request.entityKey = props.entityKey
}
const ret = await window.client.getDashboard(request)
if (requestId !== dashboardRequestId) {
return
}
if (!ret || !ret.dashboard) {
throw new Error('No dashboard found')
}
dashboard.value = ret.dashboard
const pageTitle = window.initResponse?.pageTitle || 'OliveTin'
document.title = ret.dashboard.title + ' - ' + pageTitle
// Clear any previous init error since we successfully loaded
initError.value = null
// Stop the loading timer once dashboard is loaded
if (loadingTimer) {
clearInterval(loadingTimer)
loadingTimer = null
}
// Set attribute to indicate dashboard is loaded successfully
document.body.setAttribute('loaded-dashboard', title || 'default')
} catch (e) {
if (requestId !== dashboardRequestId) {
return
}
// On error, provide a safe fallback state
console.error('Failed to load dashboard', e)
dashboard.value = { title: title || 'Default', contents: [] }
const pageTitle = window.initResponse?.pageTitle || 'OliveTin'
document.title = 'Error - ' + pageTitle
// Stop the loading timer on error
if (loadingTimer) {
clearInterval(loadingTimer)
loadingTimer = null
}
// Set attribute even on error so tests can proceed
document.body.setAttribute('loaded-dashboard', title || 'error')
if (props.entityType && props.entityKey) {
request.entityType = props.entityType
request.entityKey = props.entityKey
}
}
function waitForInitAndLoadDashboard() {
document.body.removeAttribute('loaded-dashboard')
const ret = await window.client.getDashboard(request)
if (requestId !== dashboardRequestId) {
return
}
if (!ret || !ret.dashboard) {
throw new Error('No dashboard found')
}
dashboard.value = ret.dashboard
const pageTitle = window.initResponse?.pageTitle || 'OliveTin'
document.title = ret.dashboard.title + ' - ' + pageTitle
// Clear any previous init error since we successfully loaded
initError.value = null
// Stop the loading timer once dashboard is loaded
if (loadingTimer) {
clearInterval(loadingTimer)
loadingTimer = null
clearInterval(loadingTimer)
loadingTimer = null
}
if (checkInitInterval) {
// Set attribute to indicate dashboard is loaded successfully
document.body.setAttribute('loaded-dashboard', title || 'default')
} catch (e) {
if (requestId !== dashboardRequestId) {
return
}
// On error, provide a safe fallback state
console.error('Failed to load dashboard', e)
dashboard.value = { title: title || 'Default', contents: [] }
const pageTitle = window.initResponse?.pageTitle || 'OliveTin'
document.title = 'Error - ' + pageTitle
// Stop the loading timer on error
if (loadingTimer) {
clearInterval(loadingTimer)
loadingTimer = null
}
// Set attribute even on error so tests can proceed
document.body.setAttribute('loaded-dashboard', title || 'error')
}
}
function waitForInitAndLoadDashboard () {
document.body.removeAttribute('loaded-dashboard')
if (loadingTimer) {
clearInterval(loadingTimer)
loadingTimer = null
}
if (checkInitInterval) {
clearInterval(checkInitInterval)
checkInitInterval = null
}
loadingTime.value = 0
loadingTimer = setInterval(() => {
loadingTime.value++
}, 1000)
// Check if init has completed successfully
if (window.initResponse) {
getDashboard()
} else if (window.initError) {
// Init failed, show error immediately
initError.value = window.initErrorMessage || 'Initialization failed. Please check your configuration and try again.'
// Stop the loading timer since we're showing an error
if (loadingTimer) {
clearInterval(loadingTimer)
loadingTimer = null
}
} else {
// Init hasn't completed yet, poll for completion
checkInitInterval = setInterval(() => {
if (window.initResponse) {
clearInterval(checkInitInterval)
checkInitInterval = null
}
loadingTime.value = 0
loadingTimer = setInterval(() => {
loadingTime.value++
}, 1000)
// Check if init has completed successfully
if (window.initResponse) {
getDashboard()
} else if (window.initError) {
// Init failed, show error immediately
} else if (window.initError) {
clearInterval(checkInitInterval)
checkInitInterval = null
initError.value = window.initErrorMessage || 'Initialization failed. Please check your configuration and try again.'
// Stop the loading timer since we're showing an error
if (loadingTimer) {
clearInterval(loadingTimer)
loadingTimer = null
clearInterval(loadingTimer)
loadingTimer = null
}
} else {
// Init hasn't completed yet, poll for completion
checkInitInterval = setInterval(() => {
if (window.initResponse) {
clearInterval(checkInitInterval)
checkInitInterval = null
getDashboard()
} else if (window.initError) {
clearInterval(checkInitInterval)
checkInitInterval = null
initError.value = window.initErrorMessage || 'Initialization failed. Please check your configuration and try again.'
// Stop the loading timer since we're showing an error
if (loadingTimer) {
clearInterval(loadingTimer)
loadingTimer = null
}
}
}, 100) // Check every 100ms
}
}
}, 100) // Check every 100ms
}
}
onMounted(() => {
waitForInitAndLoadDashboard()
waitForInitAndLoadDashboard()
})
watch(
() => [props.title, props.entityType, props.entityKey],
() => {
dashboard.value = null
waitForInitAndLoadDashboard()
}
() => [props.title, props.entityType, props.entityKey],
() => {
dashboard.value = null
waitForInitAndLoadDashboard()
}
)
onUnmounted(() => {
document.body.removeAttribute('loaded-dashboard')
document.body.removeAttribute('loaded-dashboard')
// Clean up the timers when component is unmounted
if (loadingTimer) {
clearInterval(loadingTimer)
loadingTimer = null
}
if (checkInitInterval) {
clearInterval(checkInitInterval)
checkInitInterval = null
}
// Clean up the timers when component is unmounted
if (loadingTimer) {
clearInterval(loadingTimer)
loadingTimer = null
}
if (checkInitInterval) {
clearInterval(checkInitInterval)
checkInitInterval = null
}
})
</script>
@ -279,7 +333,6 @@ fieldset {
place-items: stretch;
}
@keyframes spin {
from {
transform: rotate(0deg);

View File

@ -1,6 +1,6 @@
<template>
<div
:id="`execution-${executionTrackingId}`"
:id="`execution-${trackingId}`"
class="execution-button"
>
<button
@ -13,25 +13,26 @@
</template>
<script>
//import { ExecutionFeedbackButton } from '../js/ExecutionFeedbackButton.js'
// import { ExecutionFeedbackButton } from '../js/ExecutionFeedbackButton.js'
export default {
name: 'ExecutionButton',
// mixins: [ExecutionFeedbackButton],
// mixins: [ExecutionFeedbackButton],
props: {
executionTrackingId: {
type: String,
required: true
}
},
data() {
data () {
return {
trackingId: '',
ellapsed: 0,
isWaiting: true
}
},
computed: {
buttonText() {
buttonText () {
if (this.isWaiting) {
return 'Executing...'
} else {
@ -39,33 +40,34 @@ export default {
}
}
},
mounted() {
mounted () {
this.trackingId = this.executionTrackingId
this.constructFromJson(this.executionTrackingId)
},
methods: {
constructFromJson(json) {
this.executionTrackingId = json
constructFromJson (json) {
this.trackingId = json
this.ellapsed = 0
this.isWaiting = true
},
show() {
show () {
this.$emit('show')
if (window.executionDialog) {
window.executionDialog.reset()
window.executionDialog.show()
window.executionDialog.fetchExecutionResult(this.executionTrackingId)
window.executionDialog.fetchExecutionResult(this.trackingId)
}
},
onExecStatusChanged() {
onExecStatusChanged () {
this.isWaiting = false
this.domTitle = this.ellapsed + 's'
},
// Override from ExecutionFeedbackButton
onExecutionFinished(logEntry) {
onExecutionFinished (logEntry) {
if (logEntry.timedOut) {
this.renderExecutionResult('action-timeout', 'Timed out')
} else if (logEntry.blocked) {
@ -78,12 +80,12 @@ export default {
}
},
renderExecutionResult(resultCssClass, temporaryStatusMessage) {
renderExecutionResult (resultCssClass, temporaryStatusMessage) {
this.updateDom(resultCssClass, '[' + temporaryStatusMessage + ']')
this.onExecStatusChanged()
},
updateDom(resultCssClass, title) {
updateDom (resultCssClass, title) {
// For execution button, we don't need to update classes as much
// since it's a simpler component
if (resultCssClass) {

View File

@ -1,5 +1,8 @@
<template>
<span v-if="showLimits" class="action-group-limit">
<span
v-if="showLimits"
class="action-group-limit"
>
{{ t('logs.action-group-limits', { concurrent: maxConcurrent, queueSize }) }}
</span>
</template>

View File

@ -1,15 +1,21 @@
<template>
<span class="action-icon-glyph">
<HugeiconsIcon
v-if="hugeiconsModel"
:icon="hugeiconsModel"
width="1em"
height="1em"
class="action-icon-glyph-svg"
/>
<span v-else-if="decodedTextGlyphIsHtml" v-html="decodedTextGlyph"></span>
<span v-else v-text="decodedTextGlyph"></span>
</span>
<span class="action-icon-glyph">
<HugeiconsIcon
v-if="hugeiconsModel"
:icon="hugeiconsModel"
width="1em"
height="1em"
class="action-icon-glyph-svg"
/>
<span
v-else-if="decodedTextGlyphIsHtml"
v-html="decodedTextGlyph"
/>
<span
v-else
v-text="decodedTextGlyph"
/>
</span>
</template>
<script setup>
@ -22,38 +28,38 @@ const hugeiconsPrefix = 'hugeicons:'
/** Maps config values like hugeicons:CommandLineIcon to Hugeicons icon definitions. */
const hugeiconsRegistry = {
CommandLineIcon,
CommandLineIcon
}
const props = defineProps({
glyph: {
type: String,
required: false,
default: '',
},
glyph: {
type: String,
required: false,
default: ''
}
})
const hugeiconsModel = computed(() => {
if (!props.glyph) {
return CommandLineIcon
}
if (!props.glyph) {
return CommandLineIcon
}
if (!props.glyph.startsWith(hugeiconsPrefix)) {
return null
}
if (!props.glyph.startsWith(hugeiconsPrefix)) {
return null
}
const name = props.glyph.slice(hugeiconsPrefix.length)
const iconModel = hugeiconsRegistry[name]
const name = props.glyph.slice(hugeiconsPrefix.length)
const iconModel = hugeiconsRegistry[name]
return iconModel ?? CommandLineIcon
return iconModel ?? CommandLineIcon
})
const decodedTextGlyph = computed(() => {
if (hugeiconsModel.value) {
return ''
}
if (hugeiconsModel.value) {
return ''
}
return decodeHtmlEntities(props.glyph)
return decodeHtmlEntities(props.glyph)
})
const decodedTextGlyphIsHtml = computed(() => glyphLooksLikeHtml(decodedTextGlyph.value))

View File

@ -1,86 +1,91 @@
<template>
<div :class = "statusClass + ' annotation'">
<router-link v-if="showQueueLink" to="/logs/queue" class="queue-status-link">{{ statusText }}</router-link>
<span v-else>{{ statusText }}</span><span>{{ exitCodeText }}</span>
</div>
<div :class="statusClass + ' annotation'">
<router-link
v-if="showQueueLink"
to="/logs/queue"
class="queue-status-link"
>
{{ statusText }}
</router-link>
<span v-else>{{ statusText }}</span><span>{{ exitCodeText }}</span>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
logEntry: {
type: Object,
required: true
},
linkQueuedStatus: {
type: Boolean,
default: false
}
logEntry: {
type: Object,
required: true
},
linkQueuedStatus: {
type: Boolean,
default: false
}
})
function isWaitingInQueue(logEntry) {
return logEntry &&
function isWaitingInQueue (logEntry) {
return logEntry &&
!logEntry.executionFinished &&
!logEntry.executionStarted
}
const statusText = computed(() => {
const logEntry = props.logEntry
if (!logEntry) return 'unknown'
const logEntry = props.logEntry
if (!logEntry) return 'unknown'
if (logEntry.executionFinished) {
if (logEntry.blocked) {
return 'Blocked'
} else if (logEntry.timedOut) {
return 'Timed out'
} else {
return 'Completed'
}
if (logEntry.executionFinished) {
if (logEntry.blocked) {
return 'Blocked'
} else if (logEntry.timedOut) {
return 'Timed out'
} else {
return 'Completed'
}
}
if (isWaitingInQueue(logEntry)) {
return 'Queued'
}
if (isWaitingInQueue(logEntry)) {
return 'Queued'
}
return 'Still running...'
return 'Still running...'
})
const exitCodeText = computed(() => {
const logEntry = props.logEntry
if (!logEntry) return ''
if (logEntry.exitCode === 0) {
return ''
}
if (logEntry.executionFinished) {
if (logEntry.blocked || logEntry.timedOut) {
return ''
}
return ' (Exit code: ' + logEntry.exitCode + ')'
}
const logEntry = props.logEntry
if (!logEntry) return ''
if (logEntry.exitCode === 0) {
return ''
}
if (logEntry.executionFinished) {
if (logEntry.blocked || logEntry.timedOut) {
return ''
}
return ' (Exit code: ' + logEntry.exitCode + ')'
}
return ''
})
const showQueueLink = computed(() => {
return props.linkQueuedStatus && isWaitingInQueue(props.logEntry)
return props.linkQueuedStatus && isWaitingInQueue(props.logEntry)
})
const statusClass = computed(() => {
const logEntry = props.logEntry
if (!logEntry) return ''
if (logEntry.executionFinished) {
if (logEntry.blocked) {
return 'status-blocked'
} else if (logEntry.timedOut) {
return 'status-timeout'
} else if (logEntry.exitCode === 0) {
return 'status-success'
} else {
return 'status-nonzero-exit'
}
const logEntry = props.logEntry
if (!logEntry) return ''
if (logEntry.executionFinished) {
if (logEntry.blocked) {
return 'status-blocked'
} else if (logEntry.timedOut) {
return 'status-timeout'
} else if (logEntry.exitCode === 0) {
return 'status-success'
} else {
return 'status-nonzero-exit'
}
return ''
}
return ''
})
</script>
@ -110,5 +115,4 @@ const statusClass = computed(() => {
text-decoration: underline;
}
</style>

View File

@ -1,14 +1,50 @@
<template>
<div id = "breadcrumbs">
<template v-for="(link, index) in links" :key="link.name">
<router-link :to="link.href">{{ link.name }}</router-link>
<span v-if="index < links.length - 1" class="separator">
&raquo;
</span>
</template>
</div>
<div id="breadcrumbs">
<template
v-for="(link, index) in links"
:key="link.name"
>
<router-link :to="link.href">
{{ link.name }}
</router-link>
<span
v-if="index < links.length - 1"
class="separator"
>
&raquo;
</span>
</template>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const links = ref([])
watch(() => route.matched, (matched) => {
links.value = []
matched.forEach((record) => {
if (!record) return
if (record.meta && record.meta.breadcrumb) {
record.meta.breadcrumb.forEach((item) => {
links.value.push({
name: item.name,
href: item.href || record.path || '/'
})
})
} else if (record.name) {
links.value.push({
name: record.name,
href: record.path || '/'
})
}
})
}, { immediate: true })
</script>
<style scoped>
span {
color: #bbb;
@ -26,34 +62,3 @@ a:hover {
}
</style>
<script setup>
import { ref } from 'vue';
import { watch } from 'vue';
import { useRoute } from 'vue-router';
const route = useRoute();
const links = ref([]);
watch(() => route.matched, (matched) => {
links.value = [];
matched.forEach((record) => {
if (!record) return;
if (record.meta && record.meta.breadcrumb) {
record.meta.breadcrumb.forEach((item) => {
links.value.push({
name: item.name,
href: item.href || record.path || '/'
});
});
} else if (record.name) {
links.value.push({
name: record.name,
href: record.path || '/'
});
}
});
}, { immediate: true });
</script>

View File

@ -1,15 +1,28 @@
<template>
<div class="choice-checklist" :id="wrapperId">
<div
:id="wrapperId"
class="choice-checklist"
>
<div class="choice-checklist-controls">
<button type="button" class="choice-checklist-control" @click="selectAll">
<button
type="button"
class="choice-checklist-control"
@click="selectAll"
>
Select all
</button>
<button type="button" class="choice-checklist-control" @click="selectNone">
<button
type="button"
class="choice-checklist-control"
@click="selectNone"
>
Select none
</button>
</div>
<fieldset class="choice-checklist-fieldset">
<legend class="visually-hidden">{{ label || name }}</legend>
<legend class="visually-hidden">
{{ label || name }}
</legend>
<label
v-for="(choice, index) in choices"
:key="choice.value"
@ -21,7 +34,7 @@
type="checkbox"
:checked="isSelected(choice.value)"
@change="handleToggle(choice.value)"
/>
>
<span>{{ choiceLabel(choice) }}</span>
</label>
</fieldset>
@ -34,7 +47,7 @@
:required="required"
tabindex="-1"
aria-hidden="true"
/>
>
</div>
</template>
@ -86,23 +99,23 @@ function optionId (index) {
return argumentFieldOptionId(props.name, index)
}
function isSelected(value) {
function isSelected (value) {
return selectedValues.value.includes(value)
}
function emitSelection(selected) {
function emitSelection (selected) {
emit('update:modelValue', formatChecklistValue(selected))
}
function handleToggle(value) {
function handleToggle (value) {
emitSelection(toggleChoice(selectedValues.value, value))
}
function selectAll() {
function selectAll () {
emitSelection(allChoiceValues(props.choices))
}
function selectNone() {
function selectNone () {
emitSelection([])
}
</script>

View File

@ -1,8 +1,11 @@
<template>
<div class="choice-combobox" ref="rootRef">
<div
ref="rootRef"
class="choice-combobox"
>
<input
ref="searchInputRef"
:id="id"
ref="searchInputRef"
type="text"
class="choice-combobox-input"
role="combobox"
@ -17,12 +20,12 @@
@input="handleSearchInput"
@keydown="handleKeydown"
@blur="handleBlur"
/>
>
<input
:name="name"
type="hidden"
:value="modelValue"
/>
>
<ul
v-if="isOpen && filteredChoices.length > 0"
:id="listboxId"
@ -44,7 +47,10 @@
{{ choiceLabel(choice) }}
</li>
</ul>
<div v-else-if="isOpen && query" class="choice-combobox-list choice-combobox-empty">
<div
v-else-if="isOpen && query"
class="choice-combobox-list choice-combobox-empty"
>
No matching options
</div>
</div>
@ -140,11 +146,11 @@ watch([() => props.modelValue, () => props.choices], () => {
}
}, { immediate: true })
function choiceLabel(choice) {
function choiceLabel (choice) {
return choiceDisplayLabel(choice)
}
function syncFromModelValue() {
function syncFromModelValue () {
const next = syncStateFromModelValue(props.choices, props.modelValue)
query.value = next.query
@ -153,12 +159,12 @@ function syncFromModelValue() {
}
}
function selectedChoiceIndex(choices) {
function selectedChoiceIndex (choices) {
const index = choices.findIndex(choice => choice.value === props.modelValue)
return index >= 0 ? index : 0
}
function openList() {
function openList () {
document.dispatchEvent(new CustomEvent(closeOthersEvent, { detail: { id: props.id } }))
const wasClosed = !isOpen.value
isOpen.value = true
@ -167,23 +173,23 @@ function openList() {
}
}
function closeList() {
function closeList () {
isOpen.value = false
isUserFiltering.value = false
syncFromModelValue()
}
function emitValue(value) {
function emitValue (value) {
emit('update:modelValue', value)
}
function selectChoice(choice) {
function selectChoice (choice) {
emitValue(choice.value)
query.value = choiceLabel(choice)
isOpen.value = false
}
function handleFocus() {
function handleFocus () {
if (!isOpen.value) {
syncFromModelValue()
isUserFiltering.value = false
@ -192,14 +198,14 @@ function handleFocus() {
openList()
}
function handleSearchInput(event) {
function handleSearchInput (event) {
isUserFiltering.value = true
query.value = event.target.value
openList()
highlightedIndex.value = 0
}
function moveHighlight(delta) {
function moveHighlight (delta) {
if (filteredChoices.value.length === 0) {
return
}
@ -218,7 +224,7 @@ function moveHighlight(delta) {
highlightedIndex.value = nextIndex
}
function handleKeydown(event) {
function handleKeydown (event) {
if (event.key === 'ArrowDown') {
event.preventDefault()
const wasOpen = isOpen.value
@ -258,17 +264,17 @@ function handleKeydown(event) {
}
}
function handleBlur() {
function handleBlur () {
closeList()
}
function handleCloseOthers(event) {
function handleCloseOthers (event) {
if (event.detail.id !== props.id) {
closeList()
}
}
function handleOutsideMouseDown(event) {
function handleOutsideMouseDown (event) {
if (!isOpen.value || rootRef.value?.contains(event.target)) {
return
}

View File

@ -1,10 +1,22 @@
<template>
<span id="connection-banner" v-if="connectionState.showDisconnectedBanner" class="inline-notification critical user-info-connection">
<span class="connection-banner-sr-only" role="status">{{ staticAnnouncement }}</span>
<span aria-hidden="true">
<a :href="websocketDocsUrl" target="_blank" rel="noopener noreferrer" class="connection-banner-link">{{ linkText }}</a>{{ bannerSuffix }}
</span>
<span
v-if="connectionState.showDisconnectedBanner"
id="connection-banner"
class="inline-notification critical user-info-connection"
>
<span
class="connection-banner-sr-only"
role="status"
>{{ staticAnnouncement }}</span>
<span aria-hidden="true">
<a
:href="websocketDocsUrl"
target="_blank"
rel="noopener noreferrer"
class="connection-banner-link"
>{{ linkText }}</a>{{ bannerSuffix }}
</span>
</span>
</template>
<script setup>
@ -18,7 +30,7 @@ const websocketDocsUrl = 'https://docs.olivetin.app/troubleshooting/err-websocke
const linkText = computed(() => t('disconnected-banner-link-text'))
function formatShortRelative(ms) {
function formatShortRelative (ms) {
if (ms < 0) return '0s'
const secs = Math.floor(ms / 1000)
const mins = Math.floor(secs / 60)
@ -28,7 +40,7 @@ function formatShortRelative(ms) {
return `${secs}s`
}
function formatShortTime(ts) {
function formatShortTime (ts) {
if (ts == null) return '--:--'
return new Date(ts).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' })
}

View File

@ -1,23 +1,39 @@
<template>
<ActionButton v-if="component.type == 'link'" :actionData="component.action" :cssClass="component.cssClass" :key="component.title" />
<ActionButton
v-if="component.type == 'link'"
:key="component.title"
:action-data="component.action"
:css-class="component.cssClass"
/>
<DashboardComponentDirectory v-else-if="component.type == 'directory'" :component="component" />
<DashboardComponentDirectory
v-else-if="component.type == 'directory'"
:component="component"
/>
<DashboardComponentDisplay v-else-if="component.type == 'display'" :component="component" />
<DashboardComponentDisplay
v-else-if="component.type == 'display'"
:component="component"
/>
<DashboardComponentMostRecentExecution v-else-if="component.type == 'stdout-most-recent-execution'" :component="component" />
<DashboardComponentMostRecentExecution
v-else-if="component.type == 'stdout-most-recent-execution'"
:component="component"
/>
<template v-else-if="component.type == 'fieldset'">
<template v-for="subcomponent in component.contents" :key="subcomponent.title">
<DashboardComponent :component="subcomponent" />
</template>
<template v-else-if="component.type == 'fieldset'">
<template
v-for="subcomponent in component.contents"
:key="subcomponent.title"
>
<DashboardComponent :component="subcomponent" />
</template>
</template>
<div v-else>
OTHER: {{ component.type }}
{{ component }}
</div>
<div v-else>
OTHER: {{ component.type }}
{{ component }}
</div>
</template>
<script setup>
@ -26,10 +42,10 @@ import DashboardComponentMostRecentExecution from './DashboardComponentMostRecen
import DashboardComponentDirectory from './DashboardComponentDirectory.vue'
import DashboardComponentDisplay from './DashboardComponentDisplay.vue'
const props = defineProps({
component: {
type: Object,
required: true
}
defineProps({
component: {
type: Object,
required: true
}
})
</script>

View File

@ -1,8 +1,15 @@
<template>
<button class="directory-button" :class="component.cssClass" @click="navigateToDirectory">
<span class="icon" v-html="unicodeIcon"></span>
<span class="title">{{ component.title }}</span>
</button>
<button
class="directory-button"
:class="component.cssClass"
@click="navigateToDirectory"
>
<span
class="icon"
v-html="unicodeIcon"
/>
<span class="title">{{ component.title }}</span>
</button>
</template>
<script setup>
@ -12,33 +19,33 @@ import { computed } from 'vue'
const router = useRouter()
const props = defineProps({
component: {
type: Object,
required: true
}
component: {
type: Object,
required: true
}
})
function getUnicodeIcon(icon) {
if (icon === '' || !icon) {
return '&#x1f4c1;' // Default folder icon
} else {
return unescape(icon)
}
function getUnicodeIcon (icon) {
if (icon === '' || !icon) {
return '&#x1f4c1;' // Default folder icon
} else {
return unescape(icon)
}
}
const unicodeIcon = computed(() => {
return getUnicodeIcon(props.component.icon)
return getUnicodeIcon(props.component.icon)
})
function navigateToDirectory() {
const params = { title: props.component.title }
function navigateToDirectory () {
const params = { title: props.component.title }
if (props.component.entityType && props.component.entityKey) {
params.entityType = props.component.entityType
params.entityKey = props.component.entityKey
}
if (props.component.entityType && props.component.entityKey) {
params.entityType = props.component.entityType
params.entityKey = props.component.entityKey
}
router.push({ name: 'Dashboard', params })
router.push({ name: 'Dashboard', params })
}
</script>

View File

@ -1,15 +1,18 @@
<template>
<div class="display" :class="component.cssClass">
<div v-html="component.title" />
</div>
<div
class="display"
:class="component.cssClass"
>
<div v-html="component.title" />
</div>
</template>
<script setup>
const props = defineProps({
component: {
type: Object,
required: true
}
defineProps({
component: {
type: Object,
required: true
}
})
</script>

View File

@ -1,13 +1,19 @@
<template>
<div class="mre-container" :class="component.cssClass">
<div
class="mre-container"
:class="component.cssClass"
>
<router-link
v-if="executionTrackingId"
:to="`/logs/${executionTrackingId}`"
class="mre-link"
v-if="executionTrackingId"
:to="`/logs/${executionTrackingId}`"
class="mre-link"
>
<pre class="mre-output">{{ output }}</pre>
<pre class="mre-output">{{ output }}</pre>
</router-link>
<pre v-else class="mre-output fg-important">{{ output }}</pre>
<pre
v-else
class="mre-output fg-important"
>{{ output }}</pre>
</div>
</template>
@ -26,7 +32,7 @@ const output = ref('Waiting...')
const executionTrackingId = ref(null)
let unwatchButtonResults = null
function updateFromLogEntry(logEntry) {
function updateFromLogEntry (logEntry) {
if (logEntry) {
if (logEntry.output !== undefined) {
output.value = logEntry.output
@ -39,7 +45,7 @@ function updateFromLogEntry(logEntry) {
}
}
async function fetchMostRecentExecution() {
async function fetchMostRecentExecution () {
if (!props.component.title) {
output.value = 'Error: No action ID specified'
executionTrackingId.value = null

View File

@ -1,167 +1,194 @@
<template>
<Section :padding="!hasTable">
<template #title>
<span class="section-title-with-icon">
Entity:
<ActionIconGlyph v-if="definition.icon" class="entity-title-icon" :glyph="definition.icon" />
{{ definition.title }}
</span>
</template>
<template v-if="hasTable" #toolbar>
<EntityListFilter v-model="searchText" />
</template>
<Section :padding="!hasTable">
<template #title>
<span class="section-title-with-icon">
Entity:
<ActionIconGlyph
v-if="definition.icon"
class="entity-title-icon"
:glyph="definition.icon"
/>
{{ definition.title }}
</span>
</template>
<template
v-if="hasTable"
#toolbar
>
<EntityListFilter v-model="searchText" />
</template>
<p v-if="!hasTable">{{ definition.instances.length }} instances.</p>
<p v-if="!hasTable">
{{ definition.instances.length }} instances.
</p>
<template v-if="hasTable">
<p v-if="tableError" class="table-error padding" role="alert">{{ tableError }}</p>
<EntityInstancesTable
v-else
:instances="tableInstances"
:properties="definition.properties"
:total-instances="totalInstances"
v-model:page="currentPage"
v-model:page-size="pageSize"
/>
</template>
<template v-if="hasTable">
<p
v-if="tableError"
class="table-error padding"
role="alert"
>
{{ tableError }}
</p>
<EntityInstancesTable
v-else
v-model:page="currentPage"
v-model:page-size="pageSize"
:instances="tableInstances"
:properties="definition.properties"
:total-instances="totalInstances"
/>
</template>
<ul v-else>
<li v-for="inst in definition.instances" :key="inst.uniqueKey">
<router-link :to="entityDetailsRoute(inst)">
{{ inst.title }}
</router-link>
</li>
</ul>
<ul v-else>
<li
v-for="inst in definition.instances"
:key="inst.uniqueKey"
>
<router-link :to="entityDetailsRoute(inst)">
{{ inst.title }}
</router-link>
</li>
</ul>
<div v-if="usedDashboards.length > 0" :class="{ padding: hasTable }">
<h3>Used on Dashboards:</h3>
<ul>
<li v-for="dash in usedDashboards" :key="dash">
<template v-if="isEntityDirectory(dash)">
{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
</template>
<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
{{ getDashboardTitle(dash) }}
</router-link>
<span v-else>{{ dash }}</span>
</li>
</ul>
</div>
</Section>
<div
v-if="usedDashboards.length > 0"
:class="{ padding: hasTable }"
>
<h3>Used on Dashboards:</h3>
<ul>
<li
v-for="dash in usedDashboards"
:key="dash"
>
<template v-if="isEntityDirectory(dash)">
{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
</template>
<router-link
v-else-if="!dash.includes('entity:')"
:to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }"
>
{{ getDashboardTitle(dash) }}
</router-link>
<span v-else>{{ dash }}</span>
</li>
</ul>
</div>
</Section>
</template>
<script setup>
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import Section from 'picocrank/vue/components/Section.vue'
import ActionIconGlyph from './ActionIconGlyph.vue'
import EntityInstancesTable from './EntityInstancesTable.vue'
import EntityListFilter from './EntityListFilter.vue'
import { entityDetailsRoute } from '../utils/entityRoutes.js'
import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
import Section from 'picocrank/vue/components/Section.vue'
import ActionIconGlyph from './ActionIconGlyph.vue'
import EntityInstancesTable from './EntityInstancesTable.vue'
import EntityListFilter from './EntityListFilter.vue'
import { entityDetailsRoute } from '../utils/entityRoutes.js'
const props = defineProps({
definition: {
type: Object,
required: true
}
})
const props = defineProps({
definition: {
type: Object,
required: true
}
})
const searchText = ref('')
const tableInstances = ref([])
const totalInstances = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const tableError = ref('')
let fetchTimer = null
let fetchSequence = 0
const searchText = ref('')
const tableInstances = ref([])
const totalInstances = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const tableError = ref('')
let fetchTimer = null
let fetchSequence = 0
const hasTable = computed(() => (props.definition.properties?.length ?? 0) > 0)
const hasTable = computed(() => (props.definition.properties?.length ?? 0) > 0)
const usedDashboards = computed(() => filteredDashboards(props.definition.usedOnDashboards ?? []))
const usedDashboards = computed(() => filteredDashboards(props.definition.usedOnDashboards ?? []))
watch(searchText, () => {
currentPage.value = 1
scheduleFetchTableInstances()
})
watch(searchText, () => {
currentPage.value = 1
scheduleFetchTableInstances()
})
watch([currentPage, pageSize], () => {
scheduleFetchTableInstances()
})
watch([currentPage, pageSize], () => {
scheduleFetchTableInstances()
})
function filteredDashboards(dashboards) {
return dashboards.filter(d => d && !d.includes('{{'))
}
function filteredDashboards (dashboards) {
return dashboards.filter(d => d && !d.includes('{{'))
}
function isEntityDirectory(dashboardTitle) {
return dashboardTitle.endsWith(' [Entity Directory]')
}
function isEntityDirectory (dashboardTitle) {
return dashboardTitle.endsWith(' [Entity Directory]')
}
function getDashboardTitle(dashboardTitle) {
if (isEntityDirectory(dashboardTitle)) {
return dashboardTitle.slice(0, -' [Entity Directory]'.length)
}
return dashboardTitle
}
function getDashboardTitle (dashboardTitle) {
if (isEntityDirectory(dashboardTitle)) {
return dashboardTitle.slice(0, -' [Entity Directory]'.length)
}
return dashboardTitle
}
function scheduleFetchTableInstances() {
if (!hasTable.value) {
return
}
function scheduleFetchTableInstances () {
if (!hasTable.value) {
return
}
if (fetchTimer) {
clearTimeout(fetchTimer)
}
if (fetchTimer) {
clearTimeout(fetchTimer)
}
fetchTimer = setTimeout(() => {
fetchTableInstances()
}, 250)
}
fetchTimer = setTimeout(() => {
fetchTableInstances()
}, 250)
}
async function fetchTableInstances() {
if (!hasTable.value) {
return
}
async function fetchTableInstances () {
if (!hasTable.value) {
return
}
const requestId = ++fetchSequence
tableError.value = ''
try {
const response = await window.client.getEntities({
entityType: props.definition.title,
filter: searchText.value.trim(),
page: currentPage.value,
pageSize: pageSize.value
})
const requestId = ++fetchSequence
tableError.value = ''
try {
const response = await window.client.getEntities({
entityType: props.definition.title,
filter: searchText.value.trim(),
page: currentPage.value,
pageSize: pageSize.value
})
if (requestId !== fetchSequence) {
return
}
if (requestId !== fetchSequence) {
return
}
const definition = response.entityDefinitions?.find(def => def.title === props.definition.title)
tableInstances.value = definition?.instances ?? []
totalInstances.value = definition?.totalInstances ?? 0
} catch (err) {
if (requestId !== fetchSequence) {
return
}
const definition = response.entityDefinitions?.find(def => def.title === props.definition.title)
tableInstances.value = definition?.instances ?? []
totalInstances.value = definition?.totalInstances ?? 0
} catch (err) {
if (requestId !== fetchSequence) {
return
}
console.error('Failed to fetch entity instances:', err)
tableError.value = 'Failed to load entity instances.'
tableInstances.value = []
totalInstances.value = 0
}
}
console.error('Failed to fetch entity instances:', err)
tableError.value = 'Failed to load entity instances.'
tableInstances.value = []
totalInstances.value = 0
}
}
onMounted(() => {
if (hasTable.value) {
fetchTableInstances()
}
})
onMounted(() => {
if (hasTable.value) {
fetchTableInstances()
}
})
onBeforeUnmount(() => {
if (fetchTimer) {
clearTimeout(fetchTimer)
fetchTimer = null
}
})
onBeforeUnmount(() => {
if (fetchTimer) {
clearTimeout(fetchTimer)
fetchTimer = null
}
})
</script>
<style scoped>

View File

@ -1,87 +1,90 @@
<template>
<Table
:data="tableRows"
:headers="headers"
:show-pagination="false"
>
<template #cell-title="{ row, value }">
<router-link :to="entityDetailsRoute(row)">
{{ value }}
</router-link>
</template>
</Table>
<Table
:data="tableRows"
:headers="headers"
:show-pagination="false"
>
<template #cell-title="{ row, value }">
<router-link :to="entityDetailsRoute(row)">
{{ value }}
</router-link>
</template>
</Table>
<div v-if="totalInstances > 0" class="padding">
<Pagination
:total="totalInstances"
v-model:page="currentPageModel"
v-model:page-size="pageSizeModel"
item-title="entities"
/>
</div>
<div
v-if="totalInstances > 0"
class="padding"
>
<Pagination
v-model:page="currentPageModel"
v-model:page-size="pageSizeModel"
:total="totalInstances"
item-title="entities"
/>
</div>
</template>
<script setup>
import { computed } from 'vue'
import Table from 'picocrank/vue/components/Table.vue'
import Pagination from 'picocrank/vue/components/Pagination.vue'
import { entityDetailsRoute } from '../utils/entityRoutes.js'
import { computed } from 'vue'
import Table from 'picocrank/vue/components/Table.vue'
import Pagination from 'picocrank/vue/components/Pagination.vue'
import { entityDetailsRoute } from '../utils/entityRoutes.js'
const props = defineProps({
instances: {
type: Array,
required: true
},
properties: {
type: Array,
required: true
},
totalInstances: {
type: Number,
default: 0
},
page: {
type: Number,
default: 1
},
pageSize: {
type: Number,
default: 10
}
})
const props = defineProps({
instances: {
type: Array,
required: true
},
properties: {
type: Array,
required: true
},
totalInstances: {
type: Number,
default: 0
},
page: {
type: Number,
default: 1
},
pageSize: {
type: Number,
default: 10
}
})
const emit = defineEmits(['update:page', 'update:pageSize'])
const emit = defineEmits(['update:page', 'update:pageSize'])
const headers = computed(() => {
const propertyHeaders = props.properties.map(property => ({
key: property.name,
label: property.title
}))
const headers = computed(() => {
const propertyHeaders = props.properties.map(property => ({
key: property.name,
label: property.title
}))
return [
{ key: 'title', label: 'Name' },
...propertyHeaders
]
})
return [
{ key: 'title', label: 'Name' },
...propertyHeaders
]
})
const tableRows = computed(() =>
props.instances.map(instance => ({
...instance.fields,
title: instance.title,
type: instance.type,
uniqueKey: instance.uniqueKey
}))
)
const tableRows = computed(() =>
props.instances.map(instance => ({
...instance.fields,
title: instance.title,
type: instance.type,
uniqueKey: instance.uniqueKey
}))
)
const currentPageModel = computed({
get: () => props.page,
set: value => emit('update:page', value)
})
const currentPageModel = computed({
get: () => props.page,
set: value => emit('update:page', value)
})
const pageSizeModel = computed({
get: () => props.pageSize,
set: value => emit('update:pageSize', value)
})
const pageSizeModel = computed({
get: () => props.pageSize,
set: value => emit('update:pageSize', value)
})
</script>

View File

@ -1,33 +1,53 @@
<template>
<label class="input-with-icons">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor"
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
</svg>
<input
:value="modelValue"
aria-label="Filter entities"
placeholder="Filter entities..."
@input="$emit('update:modelValue', $event.target.value)"
/>
<button title="Clear search filter" :disabled="!modelValue" @click="$emit('update:modelValue', '')">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor"
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
</svg>
</button>
</label>
<label class="input-with-icons">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fill="currentColor"
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14"
/>
</svg>
<input
:value="modelValue"
aria-label="Filter entities"
placeholder="Filter entities..."
@input="$emit('update:modelValue', $event.target.value)"
>
<button
title="Clear search filter"
:disabled="!modelValue"
@click="$emit('update:modelValue', '')"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
fill="currentColor"
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"
/>
</svg>
</button>
</label>
</template>
<script setup>
defineProps({
modelValue: {
type: String,
default: ''
}
})
defineProps({
modelValue: {
type: String,
default: ''
}
})
defineEmits(['update:modelValue'])
defineEmits(['update:modelValue'])
</script>
<style scoped>

View File

@ -1,7 +1,10 @@
<template>
<span class="log-action-title">
<slot>{{ actionTitle }}</slot>
<span v-if="justification" class="log-justification">— {{ justification }}</span>
<span
v-if="justification"
class="log-justification"
>— {{ justification }}</span>
</span>
</template>

View File

@ -2,10 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from './Dashboard.vue'
import { Wrench01Icon } from '@hugeicons/core-free-icons'
import { LeftToRightListDashIcon } from '@hugeicons/core-free-icons'
import { CellsIcon } from '@hugeicons/core-free-icons'
import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
import { Wrench01Icon, LeftToRightListDashIcon, CellsIcon, DashboardSquare01Icon } from '@hugeicons/core-free-icons'
const routes = [
{
@ -44,8 +41,8 @@ const routes = [
meta: {
title: 'Logs Calendar',
breadcrumb: [
{ name: "Logs", href: "/logs" },
{ name: "Calendar" },
{ name: 'Logs', href: '/logs' },
{ name: 'Calendar' }
]
}
},
@ -56,8 +53,8 @@ const routes = [
meta: {
title: 'Execution Queue',
breadcrumb: [
{ name: "Logs", href: "/logs" },
{ name: "Queue" },
{ name: 'Logs', href: '/logs' },
{ name: 'Queue' }
]
}
},
@ -78,8 +75,8 @@ const routes = [
meta: {
title: 'OliveTin - Entity Details',
breadcrumb: [
{ name: "Entities", href: "/entities" },
{ name: "Entity Details" }
{ name: 'Entities', href: '/entities' },
{ name: 'Entity Details' }
]
}
},
@ -91,8 +88,8 @@ const routes = [
meta: {
title: 'Execution',
breadcrumb: [
{ name: "Logs", href: "/logs" },
{ name: "Execution" },
{ name: 'Logs', href: '/logs' },
{ name: 'Execution' }
]
}
},
@ -104,8 +101,8 @@ const routes = [
meta: {
title: 'Action Details',
breadcrumb: [
{ name: "Actions", href: "/" },
{ name: "Action Details" },
{ name: 'Actions', href: '/' },
{ name: 'Action Details' }
]
}
},
@ -117,8 +114,8 @@ const routes = [
meta: {
title: 'Execution conditions',
breadcrumb: [
{ name: "Actions", href: "/" },
{ name: "Execution conditions" },
{ name: 'Actions', href: '/' },
{ name: 'Execution conditions' }
]
}
},
@ -155,7 +152,7 @@ const routes = [
const router = createRouter({
history: createWebHistory(),
routes,
scrollBehavior(to, from, savedPosition) {
scrollBehavior (to, from, savedPosition) {
if (savedPosition) {
return savedPosition
} else {
@ -168,7 +165,7 @@ const router = createRouter({
router.beforeEach((to) => {
if (to.meta && to.meta.title) {
const pageTitle = window.initResponse?.pageTitle || 'OliveTin'
document.title = to.meta.title + " - " + pageTitle
document.title = to.meta.title + ' - ' + pageTitle
}
})

View File

@ -1,8 +1,8 @@
function parseLegacyChecklistValue(value) {
function parseLegacyChecklistValue (value) {
return value.split(',').map((segment) => segment.trim()).filter((segment) => segment !== '')
}
export function parseChecklistValue(value) {
export function parseChecklistValue (value) {
if (!value || value === '') {
return []
}
@ -24,7 +24,7 @@ export function parseChecklistValue(value) {
return parseLegacyChecklistValue(value)
}
export function formatChecklistValue(selected) {
export function formatChecklistValue (selected) {
if (!Array.isArray(selected) || selected.length === 0) {
return ''
}
@ -32,7 +32,7 @@ export function formatChecklistValue(selected) {
return JSON.stringify(selected)
}
export function toggleChoice(selected, value) {
export function toggleChoice (selected, value) {
const current = Array.isArray(selected) ? [...selected] : []
const index = current.indexOf(value)
@ -45,10 +45,10 @@ export function toggleChoice(selected, value) {
return current
}
export function choiceLabel(choice) {
export function choiceLabel (choice) {
return choice.title || choice.value || ''
}
export function allChoiceValues(choices) {
export function allChoiceValues (choices) {
return choices.map((choice) => choice.value)
}

View File

@ -1,4 +1,4 @@
export function applyArgumentTemplate(template, args) {
export function applyArgumentTemplate (template, args) {
if (!template) {
return ''
}
@ -6,10 +6,10 @@ export function applyArgumentTemplate(template, args) {
return template.replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, name) => args[name] ?? '')
}
export function actionRequiresJustification(justification) {
export function actionRequiresJustification (justification) {
return (justification ?? '').length > 0
}
export function actionJustificationTemplate(justification) {
export function actionJustificationTemplate (justification) {
return justification ?? ''
}

View File

@ -1,20 +1,20 @@
export function readPrefilledArgumentsFromNavigation (historyState = globalThis.window?.history?.state) {
if (historyState?.prefilledArguments && typeof historyState.prefilledArguments === 'object') {
return { ...historyState.prefilledArguments }
}
if (historyState?.prefilledArguments && typeof historyState.prefilledArguments === 'object') {
return { ...historyState.prefilledArguments }
}
return {}
return {}
}
export function getInitialArgumentValue (paramName, prefilledArguments = {}, search = globalThis.window?.location?.search ?? '') {
const safePrefilledArguments = prefilledArguments && typeof prefilledArguments === 'object'
? prefilledArguments
: {}
const safePrefilledArguments = prefilledArguments && typeof prefilledArguments === 'object'
? prefilledArguments
: {}
if (Object.prototype.hasOwnProperty.call(safePrefilledArguments, paramName)) {
return safePrefilledArguments[paramName]
}
if (Object.prototype.hasOwnProperty.call(safePrefilledArguments, paramName)) {
return safePrefilledArguments[paramName]
}
const params = new URLSearchParams(search)
return params.get(paramName)
const params = new URLSearchParams(search)
return params.get(paramName)
}

View File

@ -1,130 +1,211 @@
<template>
<Section :padding="false">
<template #title>
<span class="section-title-with-icon">
Action Details:
<ActionIconGlyph v-if="action" class="action-title-icon" :glyph="action.icon" />
{{ actionTitle }}
</span>
</template>
<template #toolbar>
<div class="action-details-toolbar">
<button
v-for="dashboard in backToDashboards"
:key="dashboard.path"
@click="goToDashboard(dashboard.path)"
:title="'Back to ' + dashboard.title"
class="button neutral"
>
<HugeiconsIcon :icon="DashboardSquare01Icon" />
{{ dashboard.title }}
</button>
<button v-if="action" @click="startAction" title="Run this action" class="button neutral">
<HugeiconsIcon :icon="WorkoutRunIcon" />
Run
</button>
<router-link
v-if="action"
:to="{ name: 'ActionExecConditions', params: { actionId: route.params.actionId } }"
class="button neutral"
title="View configured automatic triggers and on-demand execution"
>
Execution conditions ({{ executionConditionCount }})
</router-link>
</div>
</template>
<template #title>
<span class="section-title-with-icon">
Action Details:
<ActionIconGlyph
v-if="action"
class="action-title-icon"
:glyph="action.icon"
/>
{{ actionTitle }}
</span>
</template>
<template #toolbar>
<div class="action-details-toolbar">
<button
v-for="dashboard in backToDashboards"
:key="dashboard.path"
:title="'Back to ' + dashboard.title"
class="button neutral"
@click="goToDashboard(dashboard.path)"
>
<HugeiconsIcon :icon="DashboardSquare01Icon" />
{{ dashboard.title }}
</button>
<button
v-if="action"
title="Run this action"
class="button neutral"
@click="startAction"
>
<HugeiconsIcon :icon="WorkoutRunIcon" />
Run
</button>
<router-link
v-if="action"
:to="{ name: 'ActionExecConditions', params: { actionId: route.params.actionId } }"
class="button neutral"
title="View configured automatic triggers and on-demand execution"
>
Execution conditions ({{ executionConditionCount }})
</router-link>
</div>
</template>
<div class = "flex-row padding" v-if="action">
<div class = "fg1">
<dl>
<dt>Timeout</dt>
<dd>{{ action.timeout }} seconds</dd>
<div
v-if="action"
class="flex-row padding"
>
<div class="fg1">
<dl>
<dt>Timeout</dt>
<dd>{{ action.timeout }} seconds</dd>
<template v-if="actionGroups.length > 0">
<dt>
<router-link :to="{ name: 'LogsQueue' }" class="action-groups-link">Action groups</router-link>
</dt>
<dd>
<ul class="action-group-list">
<li v-for="group in actionGroups" :key="group.name" class="action-group-row">
<router-link :to="{ name: 'LogsQueue' }" class="action-groups-link action-group-name">{{ group.name }}</router-link><template v-if="group.maxConcurrent > 0 && group.queueSize > 0"> - </template><ActionGroupLimitsLabel
:max-concurrent="group.maxConcurrent"
:queue-size="group.queueSize"
/>
</li>
</ul>
</dd>
</template>
</dl>
<p class = "fg1">
Execution history for this action. You can filter by execution tracking ID.
</p>
</div>
<div style = "align-self: start; text-align: right;">
<div class="filter-container">
<label class="input-with-icons">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
<template v-if="actionGroups.length > 0">
<dt>
<router-link
:to="{ name: 'LogsQueue' }"
class="action-groups-link"
>
Action groups
</router-link>
</dt>
<dd>
<ul class="action-group-list">
<li
v-for="group in actionGroups"
:key="group.name"
class="action-group-row"
>
<router-link
:to="{ name: 'LogsQueue' }"
class="action-groups-link action-group-name"
>
{{ group.name }}
</router-link><template v-if="group.maxConcurrent > 0 && group.queueSize > 0">
-
</template><ActionGroupLimitsLabel
:max-concurrent="group.maxConcurrent"
:queue-size="group.queueSize"
/>
</li>
</ul>
</dd>
</template>
</dl>
<p class="fg1">
Execution history for this action. You can filter by execution tracking ID.
</p>
</div>
<div style="align-self: start; text-align: right;">
<div class="filter-container">
<label class="input-with-icons">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14"
/>
</svg>
<input
v-model="searchText"
placeholder="Filter current page"
>
<button
title="Clear search filter"
:disabled="!searchText"
@click="clearSearch"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"
/>
</svg>
<input placeholder="Filter current page" v-model="searchText" />
<button title="Clear search filter" :disabled="!searchText" @click="clearSearch">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
</svg>
</button>
</label>
</div>
</button>
</label>
</div>
</div>
</div>
<div v-show="filteredLogs.length > 0">
<table class="logs-table row-hover">
<thead>
<tr>
<th>Timestamp</th>
<th>Duration</th>
<th>Execution ID</th>
<th>Metadata</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr v-for="log in filteredLogs" :key="log.executionTrackingId" class="log-row" :title="log.actionTitle">
<td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td>
<td class="duration">{{ formatExecutionDuration(log) }}</td>
<td>
<router-link :to="`/logs/${log.executionTrackingId}`">
<LogActionTitle :justification="log.justification">
{{ log.executionTrackingId }}
</LogActionTitle>
</router-link>
</td>
<td class="tags">
<span class="annotation">
<span class="annotation-key">User:</span>
<span class="annotation-val">{{ log.user }}</span>
</span>
<span v-if="log.tags && log.tags.length > 0" class="tag-list">
<span v-for="tag in log.tags" :key="tag" class="tag">{{ tag }}</span>
</span>
</td>
<td class="exit-code">
<ActionStatusDisplay :logEntry="log" :link-queued-status="true" />
</td>
</tr>
</tbody>
</table>
<div v-show="filteredLogs.length > 0">
<table class="logs-table row-hover">
<thead>
<tr>
<th>Timestamp</th>
<th>Duration</th>
<th>Execution ID</th>
<th>Metadata</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr
v-for="log in filteredLogs"
:key="log.executionTrackingId"
class="log-row"
:title="log.actionTitle"
>
<td class="timestamp">
{{ formatTimestamp(log.datetimeStarted) }}
</td>
<td class="duration">
{{ formatExecutionDuration(log) }}
</td>
<td>
<router-link :to="`/logs/${log.executionTrackingId}`">
<LogActionTitle :justification="log.justification">
{{ log.executionTrackingId }}
</LogActionTitle>
</router-link>
</td>
<td class="tags">
<span class="annotation">
<span class="annotation-key">User:</span>
<span class="annotation-val">{{ log.user }}</span>
</span>
<span
v-if="log.tags && log.tags.length > 0"
class="tag-list"
>
<span
v-for="tag in log.tags"
:key="tag"
class="tag"
>{{ tag }}</span>
</span>
</td>
<td class="exit-code">
<ActionStatusDisplay
:log-entry="log"
:link-queued-status="true"
/>
</td>
</tr>
</tbody>
</table>
<Pagination :pageSize="pageSize" :total="totalCount" :currentPage="currentPage" :page="currentPage" @page-change="handlePageChange" class="padding"
@page-size-change="handlePageSizeChange" itemTitle="execution logs" />
</div>
<Pagination
:page-size="pageSize"
:total="totalCount"
:current-page="currentPage"
:page="currentPage"
class="padding"
item-title="execution logs"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</div>
<div v-show="logs.length === 0 && !loading" class="empty-state">
<p>This action has no execution history.</p>
<router-link to="/">Return to index</router-link>
</div>
<div
v-show="logs.length === 0 && !loading"
class="empty-state"
>
<p>This action has no execution history.</p>
<router-link to="/">
Return to index
</router-link>
</div>
</Section>
</template>
@ -174,16 +255,16 @@ const executionConditionCount = computed(() => countExecutionConditions(action.v
const actionGroups = computed(() => action.value?.groups ?? [])
async function fetchActionLogs() {
async function fetchActionLogs () {
loading.value = true
try {
const actionId = route.params.actionId
const startOffset = (currentPage.value - 1) * pageSize.value
const args = {
"actionId": actionId,
"startOffset": BigInt(startOffset),
"pageSize": BigInt(Number(pageSize.value)),
actionId,
startOffset: BigInt(startOffset),
pageSize: BigInt(Number(pageSize.value))
}
const response = await window.client.getActionLogs(args)
@ -203,11 +284,11 @@ async function fetchActionLogs() {
}
}
async function fetchAction() {
async function fetchAction () {
try {
const actionId = route.params.actionId
const args = {
"bindingId": actionId
bindingId: actionId
}
const response = await window.client.getActionBinding(args)
action.value = response.action
@ -219,11 +300,11 @@ async function fetchAction() {
}
}
function goToDashboard(path) {
function goToDashboard (path) {
router.push(path)
}
function resetState() {
function resetState () {
action.value = null
backToDashboards.value = []
actionTitle.value = 'Action Details'
@ -235,11 +316,11 @@ function resetState() {
syncDurationTicker()
}
function clearSearch() {
function clearSearch () {
searchText.value = ''
}
function formatTimestamp(timestamp) {
function formatTimestamp (timestamp) {
if (!timestamp) return 'Unknown'
try {
const date = new Date(timestamp)
@ -249,11 +330,11 @@ function formatTimestamp(timestamp) {
}
}
function plural(n, singular, pluralForm) {
function plural (n, singular, pluralForm) {
return n === 1 ? `1 ${singular}` : `${n} ${pluralForm}`
}
function formatDurationSimple(ms) {
function formatDurationSimple (ms) {
if (!Number.isFinite(ms) || ms < 0) {
return '—'
}
@ -274,7 +355,7 @@ function formatDurationSimple(ms) {
return parts.join(' ')
}
function formatExecutionDuration(log) {
function formatExecutionDuration (log) {
// Reading durationClock keeps this column reactive while executions are in progress.
const clock = durationClock.value
@ -300,7 +381,7 @@ function formatExecutionDuration(log) {
return formatDurationSimple(endMs - started.getTime())
}
function syncDurationTicker() {
function syncDurationTicker () {
if (durationTicker != null) {
clearInterval(durationTicker)
durationTicker = null
@ -314,18 +395,18 @@ function syncDurationTicker() {
}, 1000)
}
function handlePageChange(page) {
function handlePageChange (page) {
currentPage.value = page
fetchActionLogs()
}
function handlePageSizeChange(newPageSize) {
function handlePageSizeChange (newPageSize) {
pageSize.value = newPageSize
currentPage.value = 1
fetchActionLogs()
}
async function startAction() {
async function startAction () {
if (!action.value || !action.value.bindingId) {
console.error('Cannot start action: no binding ID')
return
@ -351,7 +432,7 @@ async function startAction() {
}
}
function onExecutionEvent(evt) {
function onExecutionEvent (evt) {
const logEntry = getExecutionLogEntry(evt)
if (!logEntry || logEntry.bindingId !== route.params.actionId) {
return

View File

@ -1,12 +1,21 @@
<template>
<Section :title="'Execution conditions: ' + actionTitle" :padding="false">
<Section
:title="'Execution conditions: ' + actionTitle"
:padding="false"
>
<template #toolbar>
<router-link :to="{ name: 'ActionDetails', params: { actionId: route.params.actionId } }" class="button neutral">
<router-link
:to="{ name: 'ActionDetails', params: { actionId: route.params.actionId } }"
class="button neutral"
>
Back to action details
</router-link>
</template>
<div v-if="action" class="padding content">
<div
v-if="action"
class="padding content"
>
<p>
These entries mirror the automatic triggers from your OliveTin configuration for this action.
You can always run the action manually as well.
@ -14,7 +23,12 @@
<h3 class="exec-type-heading">
On click
<a class="doc-link" :href="execConditionDocs.onDemand" target="_blank" rel="noopener noreferrer">Documentation</a>
<a
class="doc-link"
:href="execConditionDocs.onDemand"
target="_blank"
rel="noopener noreferrer"
>Documentation</a>
</h3>
<p>
Manual execution from the web UI (dashboard or action details), or via the API (for example StartAction),
@ -24,7 +38,12 @@
<template v-if="action.execOnStartup">
<h3 class="exec-type-heading">
<code>execOnStartup</code>
<a class="doc-link" :href="execConditionDocs.startup" target="_blank" rel="noopener noreferrer">Documentation</a>
<a
class="doc-link"
:href="execConditionDocs.startup"
target="_blank"
rel="noopener noreferrer"
>Documentation</a>
</h3>
<p>Runs once when OliveTin starts.</p>
</template>
@ -32,37 +51,72 @@
<template v-if="nonEmptyList(action.execOnCron)">
<h3 class="exec-type-heading">
<code>execOnCron</code>
<a class="doc-link" :href="execConditionDocs.cron" target="_blank" rel="noopener noreferrer">Documentation</a>
<a
class="doc-link"
:href="execConditionDocs.cron"
target="_blank"
rel="noopener noreferrer"
>Documentation</a>
</h3>
<ul>
<li v-for="(line, idx) in action.execOnCron" :key="'cron-' + idx"><code>{{ line }}</code></li>
<li
v-for="(line, idx) in action.execOnCron"
:key="'cron-' + idx"
>
<code>{{ line }}</code>
</li>
</ul>
</template>
<template v-if="nonEmptyList(action.execOnFileCreatedInDir)">
<h3 class="exec-type-heading">
<code>execOnFileCreatedInDir</code>
<a class="doc-link" :href="execConditionDocs.fileCreated" target="_blank" rel="noopener noreferrer">Documentation</a>
<a
class="doc-link"
:href="execConditionDocs.fileCreated"
target="_blank"
rel="noopener noreferrer"
>Documentation</a>
</h3>
<ul>
<li v-for="(dir, idx) in action.execOnFileCreatedInDir" :key="'created-' + idx"><code>{{ dir }}</code></li>
<li
v-for="(dir, idx) in action.execOnFileCreatedInDir"
:key="'created-' + idx"
>
<code>{{ dir }}</code>
</li>
</ul>
</template>
<template v-if="nonEmptyList(action.execOnFileChangedInDir)">
<h3 class="exec-type-heading">
<code>execOnFileChangedInDir</code>
<a class="doc-link" :href="execConditionDocs.fileChanged" target="_blank" rel="noopener noreferrer">Documentation</a>
<a
class="doc-link"
:href="execConditionDocs.fileChanged"
target="_blank"
rel="noopener noreferrer"
>Documentation</a>
</h3>
<ul>
<li v-for="(dir, idx) in action.execOnFileChangedInDir" :key="'changed-' + idx"><code>{{ dir }}</code></li>
<li
v-for="(dir, idx) in action.execOnFileChangedInDir"
:key="'changed-' + idx"
>
<code>{{ dir }}</code>
</li>
</ul>
</template>
<template v-if="action.execOnCalendarFile">
<h3 class="exec-type-heading">
<code>execOnCalendarFile</code>
<a class="doc-link" :href="execConditionDocs.calendar" target="_blank" rel="noopener noreferrer">Documentation</a>
<a
class="doc-link"
:href="execConditionDocs.calendar"
target="_blank"
rel="noopener noreferrer"
>Documentation</a>
</h3>
<p><code>{{ action.execOnCalendarFile }}</code></p>
</template>
@ -70,10 +124,18 @@
<template v-if="nonEmptyList(action.execOnWebhooks)">
<h3 class="exec-type-heading">
<code>execOnWebhook</code>
<a class="doc-link" :href="execConditionDocs.webhook" target="_blank" rel="noopener noreferrer">Documentation</a>
<a
class="doc-link"
:href="execConditionDocs.webhook"
target="_blank"
rel="noopener noreferrer"
>Documentation</a>
</h3>
<ul class="webhook-list">
<li v-for="(wh, idx) in action.execOnWebhooks" :key="'wh-' + idx">
<li
v-for="(wh, idx) in action.execOnWebhooks"
:key="'wh-' + idx"
>
<span v-if="wh.template">template: <code>{{ wh.template }}</code></span>
<span v-if="wh.matchPath"> · matchPath: <code>{{ wh.matchPath }}</code></span>
<span v-if="nonEmptyObject(wh.matchHeaders)"> · matchHeaders: <code>{{ wh.matchHeaders }}</code></span>
@ -83,14 +145,22 @@
</ul>
</template>
<p v-if="!hasConfiguredTriggers" class="muted">
<p
v-if="!hasConfiguredTriggers"
class="muted"
>
This action has no automatic triggers in configuration besides on-demand execution.
</p>
</div>
<div v-else-if="!loading" class="padding empty-state">
<div
v-else-if="!loading"
class="padding empty-state"
>
<p>Could not load this action.</p>
<router-link :to="{ name: 'Actions' }">Return to index</router-link>
<router-link :to="{ name: 'Actions' }">
Return to index
</router-link>
</div>
</Section>
</template>
@ -112,18 +182,18 @@ const execConditionDocs = {
fileCreated: 'https://docs.olivetin.app/action_execution/onfilecreated.html',
fileChanged: 'https://docs.olivetin.app/action_execution/onfilechanged.html',
calendar: 'https://docs.olivetin.app/action_execution/oncalendar.html',
webhook: 'https://docs.olivetin.app/action_execution/onwebhook.html',
webhook: 'https://docs.olivetin.app/action_execution/onwebhook.html'
}
function nonEmptyList(list) {
function nonEmptyList (list) {
return Array.isArray(list) && list.length > 0
}
function nonEmptyObject(object) {
function nonEmptyObject (object) {
return object && Object.keys(object).length > 0
}
function webhookHasCondition(webhook) {
function webhookHasCondition (webhook) {
return webhook.template || webhook.matchPath || nonEmptyObject(webhook.matchHeaders) || nonEmptyObject(webhook.matchQuery)
}
@ -147,7 +217,7 @@ const hasConfiguredTriggers = computed(() => {
return false
})
async function fetchAction() {
async function fetchAction () {
loading.value = true
try {
const actionId = route.params.actionId

View File

@ -1,5 +1,5 @@
<template>
<section id = "argument-popup">
<section id="argument-popup">
<div class="section-header">
<h2>
<span class="section-title-with-icon">
@ -8,7 +8,11 @@
:to="`/action/${bindingId}`"
class="action-details-title-link"
>
<ActionIconGlyph v-if="icon" class="action-title-icon" :glyph="icon" />
<ActionIconGlyph
v-if="icon"
class="action-title-icon"
:glyph="icon"
/>
{{ title }}
</router-link>
</span>
@ -17,48 +21,96 @@
<div class="section-content padding">
<form @submit="handleSubmit">
<template v-if="actionArguments.length > 0">
<template
v-for="arg in actionArguments"
:key="arg.name"
>
<label
v-if="arg.type !== 'checklist'"
:for="argumentFieldId(arg.name)"
>
{{ formatLabel(arg.title) }}
</label>
<div
v-else
class="argument-label"
>
{{ formatLabel(arg.title) }}
</div>
<template v-for="arg in actionArguments" :key="arg.name">
<label v-if="arg.type !== 'checklist'" :for="argumentFieldId(arg.name)">
{{ formatLabel(arg.title) }}
</label>
<div v-else class="argument-label">
{{ formatLabel(arg.title) }}
</div>
<datalist
v-if="(arg.suggestions && Object.keys(arg.suggestions).length > 0) || getBrowserSuggestions(arg).length > 0"
:id="argumentFieldChoicesId(arg.name)"
>
<option
v-for="(suggestion, key) in arg.suggestions"
:key="key"
:value="key"
>
{{ suggestion }}
</option>
<option
v-for="(suggestion, index) in getBrowserSuggestions(arg)"
:key="`browser-${index}`"
:value="suggestion"
>
{{ suggestion }}
</option>
</datalist>
<datalist v-if="(arg.suggestions && Object.keys(arg.suggestions).length > 0) || getBrowserSuggestions(arg).length > 0" :id="argumentFieldChoicesId(arg.name)">
<option v-for="(suggestion, key) in arg.suggestions" :key="key" :value="key">
{{ suggestion }}
</option>
<option v-for="(suggestion, index) in getBrowserSuggestions(arg)" :key="`browser-${index}`" :value="suggestion">
{{ suggestion }}
</option>
</datalist>
<ChoiceCombobox
v-if="getInputComponent(arg) === 'select'"
:id="argumentFieldId(arg.name)"
:name="arg.name"
:choices="arg.choices"
:model-value="getArgumentValue(arg)"
:required="arg.required"
@update:model-value="handleChoiceUpdate(arg, $event)"
/>
<ChoiceCombobox v-if="getInputComponent(arg) === 'select'" :id="argumentFieldId(arg.name)" :name="arg.name"
:choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
@update:model-value="handleChoiceUpdate(arg, $event)" />
<ChoiceChecklist
v-else-if="arg.type === 'checklist'"
:name="arg.name"
:label="arg.title"
:choices="arg.choices"
:model-value="getArgumentValue(arg)"
:required="arg.required"
@update:model-value="handleChoiceUpdate(arg, $event)"
/>
<ChoiceChecklist v-else-if="arg.type === 'checklist'" :name="arg.name"
:label="arg.title" :choices="arg.choices" :model-value="getArgumentValue(arg)" :required="arg.required"
@update:model-value="handleChoiceUpdate(arg, $event)" />
<component
:is="getInputComponent(arg)"
v-else
:id="argumentFieldId(arg.name)"
:name="arg.name"
:value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
:checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
:list="(arg.suggestions || getBrowserSuggestions(arg).length > 0) ? argumentFieldChoicesId(arg.name) : undefined"
:type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
:rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
:step="arg.type === 'datetime' ? 1 : undefined"
:pattern="getPattern(arg)"
@input="handleInput(arg, $event)"
@change="handleChange(arg, $event)"
/>
<component v-else :is="getInputComponent(arg)" :id="argumentFieldId(arg.name)" :name="arg.name"
:value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
:checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
:list="(arg.suggestions || getBrowserSuggestions(arg).length > 0) ? argumentFieldChoicesId(arg.name) : undefined"
:type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
:rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
:step="arg.type === 'datetime' ? 1 : undefined" :pattern="getPattern(arg)"
@input="handleInput(arg, $event)" @change="handleChange(arg, $event)" />
<span class="argument-description" v-html="arg.description"></span>
<span
class="argument-description"
v-html="arg.description"
/>
</template>
</template>
<template v-if="justificationRequired">
<label for="justification">Justification:</label>
<input id="justification" name="justification" type="text" :value="justificationValue" required @input="handleJustificationInput" />
<input
id="justification"
name="justification"
type="text"
:value="justificationValue"
required
@input="handleJustificationInput"
>
</template>
<div v-if="actionArguments.length === 0 && !justificationRequired">
@ -66,10 +118,18 @@
</div>
<div class="buttons">
<button name="start" type="submit" :disabled="!formReady || (hasConfirmation && !confirmationChecked)">
<button
name="start"
type="submit"
:disabled="!formReady || (hasConfirmation && !confirmationChecked)"
>
Start
</button>
<button name="cancel" type="button" @click="handleCancel">
<button
name="cancel"
type="button"
@click="handleCancel"
>
Cancel
</button>
</div>
@ -103,7 +163,7 @@ const router = useRouter()
const dialog = ref(null)
const title = ref('')
const icon = ref('')
//const arguments = ref([])
// const arguments = ref([])
const argValues = ref({})
const confirmationChecked = ref(false)
const hasConfirmation = ref(false)
@ -128,7 +188,7 @@ const props = defineProps({
})
// Methods
async function setup() {
async function setup () {
formReady.value = false
document.body.removeAttribute('loaded-argument-form')
@ -155,32 +215,32 @@ async function setup() {
// Initialize values from navigation state, query params, or defaults
actionArguments.value.forEach(arg => {
if (arg.type === 'confirmation') {
hasConfirmation.value = true
const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
let checkedValue = false
if (paramValue !== null) {
checkedValue = paramValue === '1' || paramValue === 'true' || paramValue === true
} else if (arg.defaultValue !== undefined && arg.defaultValue !== '') {
checkedValue = arg.defaultValue === '1' || arg.defaultValue === 'true' || arg.defaultValue === true
}
argValues.value[arg.name] = checkedValue
confirmationChecked.value = checkedValue
} else {
const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
if (arg.type === 'checkbox') {
// For checkboxes, handle boolean default values properly
if (arg.type === 'confirmation') {
hasConfirmation.value = true
const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
let checkedValue = false
if (paramValue !== null) {
argValues.value[arg.name] = paramValue === '1' || paramValue === 'true' || paramValue === true
checkedValue = paramValue === '1' || paramValue === 'true' || paramValue === true
} else if (arg.defaultValue !== undefined && arg.defaultValue !== '') {
argValues.value[arg.name] = arg.defaultValue === '1' || arg.defaultValue === 'true' || arg.defaultValue === true
} else {
argValues.value[arg.name] = false
checkedValue = arg.defaultValue === '1' || arg.defaultValue === 'true' || arg.defaultValue === true
}
argValues.value[arg.name] = checkedValue
confirmationChecked.value = checkedValue
} else {
argValues.value[arg.name] = paramValue !== null ? paramValue : arg.defaultValue || ''
const paramValue = getInitialArgumentValue(arg.name, prefilledArguments)
if (arg.type === 'checkbox') {
// For checkboxes, handle boolean default values properly
if (paramValue !== null) {
argValues.value[arg.name] = paramValue === '1' || paramValue === 'true' || paramValue === true
} else if (arg.defaultValue !== undefined && arg.defaultValue !== '') {
argValues.value[arg.name] = arg.defaultValue === '1' || arg.defaultValue === 'true' || arg.defaultValue === true
} else {
argValues.value[arg.name] = false
}
} else {
argValues.value[arg.name] = paramValue !== null ? paramValue : arg.defaultValue || ''
}
}
}
})
// Run initial validation on all fields after DOM is updated
@ -202,7 +262,7 @@ async function setup() {
}
}
function formatLabel(title) {
function formatLabel (title) {
const lastChar = title.charAt(title.length - 1)
if (lastChar === '?' || lastChar === '.' || lastChar === ':') {
return title
@ -210,7 +270,7 @@ function formatLabel(title) {
return title + ':'
}
function getInputComponent(arg) {
function getInputComponent (arg) {
if (arg.type === 'html') {
return 'div'
} else if (arg.type === 'raw_string_multiline') {
@ -222,7 +282,7 @@ function getInputComponent(arg) {
}
}
function getInputType(arg) {
function getInputType (arg) {
if (arg.type === 'html' || arg.type === 'raw_string_multiline' || arg.type === 'select') {
return undefined
}
@ -242,33 +302,33 @@ function getInputType(arg) {
return arg.type
}
function getPattern(arg) {
function getPattern (arg) {
if (arg.type && arg.type.startsWith('regex:')) {
return arg.type.replace('regex:', '')
}
return undefined
}
function getArgumentValue(arg) {
function getArgumentValue (arg) {
if (arg.type === 'checkbox' || arg.type === 'confirmation') {
return argValues.value[arg.name] === '1' || argValues.value[arg.name] === true || argValues.value[arg.name] === 'true'
}
return argValues.value[arg.name] || ''
}
function handleJustificationInput(event) {
function handleJustificationInput (event) {
justificationValue.value = event.target.value
justificationEditedManually.value = true
}
function handleInput(arg, event) {
function handleInput (arg, event) {
const value = event.target.type === 'checkbox' ? event.target.checked : event.target.value
argValues.value[arg.name] = value
updateUrlWithArg(arg.name, value)
updateJustificationFromTemplate()
}
function handleChange(arg, event) {
function handleChange (arg, event) {
if (arg.type === 'confirmation') {
confirmationChecked.value = event.target.checked
return
@ -278,18 +338,18 @@ function handleChange(arg, event) {
validateArgument(arg, event.target.value)
}
function getValidationElement(arg) {
function getValidationElement (arg) {
return document.getElementById(argumentFieldValidationElementId(arg))
}
function handleChoiceUpdate(arg, value) {
function handleChoiceUpdate (arg, value) {
argValues.value[arg.name] = value
updateUrlWithArg(arg.name, value)
validateArgument(arg, value)
updateJustificationFromTemplate()
}
async function validateArgument(arg, value) {
async function validateArgument (arg, value) {
if (!arg.type || arg.type.startsWith('regex:')) {
return
}
@ -316,7 +376,7 @@ async function validateArgument(arg, value) {
try {
const validateArgumentTypeArgs = {
value: value,
value,
type: arg.type,
bindingId: props.bindingId,
argumentName: arg.name
@ -348,7 +408,7 @@ async function validateArgument(arg, value) {
}
}
function updateUrlWithArg(name, value) {
function updateUrlWithArg (name, value) {
if (name && value !== undefined) {
const url = new URL(window.location.href)
@ -363,7 +423,7 @@ function updateUrlWithArg(name, value) {
}
}
function shouldSendArgument(arg) {
function shouldSendArgument (arg) {
if (!arg.name) {
return false
}
@ -371,7 +431,7 @@ function shouldSendArgument(arg) {
return arg.type !== 'html'
}
function formatArgumentValueForApi(arg, rawValue) {
function formatArgumentValueForApi (arg, rawValue) {
if (arg.type === 'checkbox' || arg.type === 'confirmation') {
return rawValue === '1' || rawValue === true || rawValue === 'true' ? '1' : '0'
}
@ -387,7 +447,7 @@ function formatArgumentValueForApi(arg, rawValue) {
return rawValue ?? ''
}
function getSelectedArgumentEntries() {
function getSelectedArgumentEntries () {
const entries = []
for (const arg of actionArguments.value) {
@ -404,17 +464,17 @@ function getSelectedArgumentEntries() {
return entries
}
function getArgumentValues() {
function getArgumentValues () {
return getSelectedArgumentEntries().map(({ name, value }) => ({ name, value }))
}
function getArgumentMapForTemplate() {
function getArgumentMapForTemplate () {
return Object.fromEntries(
getSelectedArgumentEntries().map(({ name, value }) => [name, value])
)
}
function updateJustificationFromTemplate() {
function updateJustificationFromTemplate () {
if (!justificationTemplate.value || justificationEditedManually.value) {
return
}
@ -422,7 +482,7 @@ function updateJustificationFromTemplate() {
justificationValue.value = applyArgumentTemplate(justificationTemplate.value, getArgumentMapForTemplate())
}
function getUniqueId() {
function getUniqueId () {
if (window.isSecureContext) {
return window.crypto.randomUUID()
} else {
@ -430,7 +490,7 @@ function getUniqueId() {
}
}
function getBrowserSuggestions(arg) {
function getBrowserSuggestions (arg) {
if (!arg.suggestionsBrowserKey) {
return []
}
@ -448,7 +508,7 @@ function getBrowserSuggestions(arg) {
return []
}
function saveBrowserSuggestions() {
function saveBrowserSuggestions () {
for (const arg of actionArguments.value) {
if (arg.suggestionsBrowserKey) {
const value = argValues.value[arg.name]
@ -484,7 +544,7 @@ function saveBrowserSuggestions() {
}
}
async function startAction(actionArgs) {
async function startAction (actionArgs) {
const startActionArgs = {
bindingId: props.bindingId,
arguments: actionArgs,
@ -506,7 +566,7 @@ async function startAction(actionArgs) {
}
}
async function handleSubmit(event) {
async function handleSubmit (event) {
event.preventDefault()
if (!formReady.value) {
@ -568,24 +628,24 @@ async function handleSubmit(event) {
}
}
function handleCancel() {
function handleCancel () {
router.back()
clearBookmark()
}
function clearBookmark() {
function clearBookmark () {
window.history.replaceState({
path: window.location.pathname
}, '', window.location.pathname)
}
function show() {
function show () {
if (dialog.value) {
dialog.value.showModal()
}
}
function close() {
function close () {
if (dialog.value) {
dialog.value.close()
}
@ -638,7 +698,6 @@ form {
grid-template-columns: max-content auto auto;
}
.argument-description {
font-size: 0.875rem;
color: #666;

View File

@ -1,10 +1,14 @@
<template>
<Section :title="t('diagnostics.get-support')">
<p>{{ t('diagnostics.get-support-description') }}
<p>
{{ t('diagnostics.get-support-description') }}
</p>
<ul>
<li>
<a href = "https://docs.olivetin.app/troubleshooting/wheretofindhelp.html" target="_blank">{{ t('diagnostics.where-to-find-help') }}</a>
<a
href="https://docs.olivetin.app/troubleshooting/wheretofindhelp.html"
target="_blank"
>{{ t('diagnostics.where-to-find-help') }}</a>
</li>
</ul>
</Section>
@ -21,26 +25,61 @@
<Section :title="t('diagnostics.server-diagnostics')">
<p>{{ t('diagnostics.server-diagnostics-description') }}</p>
<p>
<a href="https://docs.olivetin.app/troubleshooting/server-diagnostics.html" target="_blank">{{ t('diagnostics.server-diagnostics-docs') }}</a>
<a
href="https://docs.olivetin.app/troubleshooting/server-diagnostics.html"
target="_blank"
>{{ t('diagnostics.server-diagnostics-docs') }}</a>
</p>
<div role="toolbar">
<button @click="generateServerDiagnostics" :disabled="loading" class = "good">{{ t('diagnostics.generate-server-diagnostics') }}</button>
<button @click="copyServerDiagnostics" :disabled="!serverDiagnostics || loading" :class="serverDiagnosticsCopied ? 'good' : ''">{{ serverDiagnosticsCopied ? t('diagnostics.copied') : t('diagnostics.copy-to-clipboard') }}</button>
<button
:disabled="loading"
class="good"
@click="generateServerDiagnostics"
>
{{ t('diagnostics.generate-server-diagnostics') }}
</button>
<button
:disabled="!serverDiagnostics || loading"
:class="serverDiagnosticsCopied ? 'good' : ''"
@click="copyServerDiagnostics"
>
{{ serverDiagnosticsCopied ? t('diagnostics.copied') : t('diagnostics.copy-to-clipboard') }}
</button>
</div>
<textarea v-model="serverDiagnostics" readonly style="flex: 1; min-height: 200px; resize: vertical; width: 100%; box-sizing: border-box;"></textarea>
<textarea
v-model="serverDiagnostics"
readonly
style="flex: 1; min-height: 200px; resize: vertical; width: 100%; box-sizing: border-box;"
/>
</Section>
<Section :title="t('diagnostics.browser-info')">
<p>{{ t('diagnostics.browser-info-description') }}</p>
<div role="toolbar">
<button @click="generateBrowserInfo" :disabled="loading" class = "good">{{ t('diagnostics.generate-browser-info') }}</button>
<button @click="copyBrowserInfo" :disabled="!browserInfo || loading" :class="browserInfoCopied ? 'good' : ''">{{ browserInfoCopied ? t('diagnostics.copied') : t('diagnostics.copy-to-clipboard') }}</button>
<button
:disabled="loading"
class="good"
@click="generateBrowserInfo"
>
{{ t('diagnostics.generate-browser-info') }}
</button>
<button
:disabled="!browserInfo || loading"
:class="browserInfoCopied ? 'good' : ''"
@click="copyBrowserInfo"
>
{{ browserInfoCopied ? t('diagnostics.copied') : t('diagnostics.copy-to-clipboard') }}
</button>
</div>
<textarea v-model="browserInfo" readonly style="flex: 1; min-height: 200px; resize: vertical; width: 100%; box-sizing: border-box;"></textarea>
<textarea
v-model="browserInfo"
readonly
style="flex: 1; min-height: 200px; resize: vertical; width: 100%; box-sizing: border-box;"
/>
</Section>
</template>
@ -58,17 +97,17 @@ const browserInfo = ref('')
const serverDiagnosticsCopied = ref(false)
const browserInfoCopied = ref(false)
async function fetchDiagnostics() {
async function fetchDiagnostics () {
loading.value = true
try {
const response = await window.client.getDiagnostics();
const response = await window.client.getDiagnostics()
diagnostics.value = {
sshFoundKey: response.SshFoundKey,
sshFoundConfig: response.SshFoundConfig
};
}
} catch (err) {
console.error('Failed to fetch diagnostics:', err);
console.error('Failed to fetch diagnostics:', err)
diagnostics.value = {
sshFoundKey: t('diagnostics.unknown'),
sshFoundConfig: t('diagnostics.unknown')
@ -77,20 +116,13 @@ async function fetchDiagnostics() {
loading.value = false
}
function formatKey(key) {
return key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.trim()
}
async function generateServerDiagnostics() {
async function generateServerDiagnostics () {
const response = await window.client.serverDiagnostics()
console.log("response", response)
console.log('response', response)
serverDiagnostics.value = `\`\`\`\n${response.alert}\n\`\`\`\n`
}
async function copyServerDiagnostics() {
async function copyServerDiagnostics () {
try {
await navigator.clipboard.writeText(serverDiagnostics.value)
serverDiagnosticsCopied.value = true
@ -102,7 +134,7 @@ async function copyServerDiagnostics() {
}
}
async function generateBrowserInfo() {
async function generateBrowserInfo () {
loading.value = true
try {
let userAgentData = 'N/A'
@ -159,7 +191,7 @@ async function generateBrowserInfo() {
})(),
hardwareConcurrency: navigator.hardwareConcurrency || 'N/A',
maxTouchPoints: navigator.maxTouchPoints || 'N/A',
userAgentData: userAgentData
userAgentData
}
const showVersionNumber = window.initResponse?.effectivePolicy?.showVersionNumber ?? true
@ -168,20 +200,20 @@ async function generateBrowserInfo() {
: '[hidden]'
const currentLanguage = locale.value || t('diagnostics.unknown')
let output = '';
output += `\`\`\`\n`
let output = ''
output += '```\n'
output += '### BROWSER INFO START (copy all text to BROWSER INFO END)\n'
output += `# OliveTin Information\n`
output += '# OliveTin Information\n'
output += `olivetinVersion: ${olivetinVersion}\n`
output += `currentLanguage: ${currentLanguage}\n`
output += `\n# Browser Information\n`
output += '\n# Browser Information\n'
output += `userAgent: ${info.userAgent}\n`
output += `platform: ${info.platform}\n`
output += `language: ${info.language}\n`
output += `languages: ${info.languages}\n`
output += `\n# User Agent Data\n`
output += '\n# User Agent Data\n'
output += `userAgentData:\n${info.userAgentData}\n`
output += `\n# Display Information\n`
output += '\n# Display Information\n'
output += `screenWidth: ${info.screenWidth}\n`
output += `screenHeight: ${info.screenHeight}\n`
output += `screenColorDepth: ${info.screenColorDepth}\n`
@ -189,18 +221,18 @@ async function generateBrowserInfo() {
output += `viewportWidth: ${info.viewportWidth}\n`
output += `viewportHeight: ${info.viewportHeight}\n`
output += `devicePixelRatio: ${info.devicePixelRatio}\n`
output += `\n# Feature Support\n`
output += '\n# Feature Support\n'
output += `cookieEnabled: ${info.cookieEnabled}\n`
output += `localStorageEnabled: ${info.localStorageEnabled}\n`
output += `sessionStorageEnabled: ${info.sessionStorageEnabled}\n`
output += `onLine: ${info.onLine}\n`
output += `hardwareConcurrency: ${info.hardwareConcurrency}\n`
output += `maxTouchPoints: ${info.maxTouchPoints}\n`
output += `\n# Location & Time\n`
output += '\n# Location & Time\n'
output += `timezone: ${info.timezone}\n`
output += `timezoneOffset: ${info.timezoneOffset}\n`
output += `\n### BROWSER INFO END (copy all text from BROWSER INFO START)`
output += `\n\`\`\`\n`
output += '\n### BROWSER INFO END (copy all text from BROWSER INFO START)'
output += '\n```\n'
browserInfo.value = output
} finally {
@ -208,7 +240,7 @@ async function generateBrowserInfo() {
}
}
async function copyBrowserInfo() {
async function copyBrowserInfo () {
try {
await navigator.clipboard.writeText(browserInfo.value)
browserInfoCopied.value = true

View File

@ -1,51 +1,54 @@
<template>
<Section v-if="!definitionsLoaded" title="Loading entity definitions..." />
<Section
v-else-if="totalInstances === 0"
title="There are no entities to show yet."
>
<p>
When OliveTin has registered entity instances (for example from entity files or your setup), they will be listed here.
</p>
</Section>
<template v-else>
<EntityDefinitionSection
v-for="def in entityDefinitions"
:key="def.title"
:definition="def"
/>
</template>
<Section
v-if="!definitionsLoaded"
title="Loading entity definitions..."
/>
<Section
v-else-if="totalInstances === 0"
title="There are no entities to show yet."
>
<p>
When OliveTin has registered entity instances (for example from entity files or your setup), they will be listed here.
</p>
</Section>
<template v-else>
<EntityDefinitionSection
v-for="def in entityDefinitions"
:key="def.title"
:definition="def"
/>
</template>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import Section from 'picocrank/vue/components/Section.vue'
import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
import { ref, computed, onMounted } from 'vue'
import Section from 'picocrank/vue/components/Section.vue'
import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
const definitionsLoaded = ref(false)
const entityDefinitions = ref([])
const definitionsLoaded = ref(false)
const entityDefinitions = ref([])
const totalInstances = computed(() =>
entityDefinitions.value.reduce(
(sum, def) => sum + (def.instances?.length ?? 0),
0,
),
)
const totalInstances = computed(() =>
entityDefinitions.value.reduce(
(sum, def) => sum + (def.instances?.length ?? 0),
0
)
)
async function fetchEntities() {
try {
const ret = await window.client.getEntities()
entityDefinitions.value = ret.entityDefinitions ?? []
} catch (err) {
console.error('Failed to fetch entities:', err)
window.showBigError('fetch-entities', 'getting entities', err, false)
entityDefinitions.value = []
} finally {
definitionsLoaded.value = true
}
}
async function fetchEntities () {
try {
const ret = await window.client.getEntities()
entityDefinitions.value = ret.entityDefinitions ?? []
} catch (err) {
console.error('Failed to fetch entities:', err)
window.showBigError('fetch-entities', 'getting entities', err, false)
entityDefinitions.value = []
} finally {
definitionsLoaded.value = true
}
}
onMounted(() => {
onMounted(() => {
fetchEntities()
})
})
</script>

View File

@ -1,130 +1,176 @@
<template>
<Section>
<template #title>
<span class="section-title-with-icon">
Entity Details:
<ActionIconGlyph v-if="entityIcon" class="entity-title-icon" :glyph="entityIcon" />
<span v-if="entityDetails?.title">{{ entityDetails.title }}</span>
</span>
</template>
<template #toolbar>
<button @click="goBack" class="back-button">
<HugeiconsIcon :icon="ArrowLeftIcon" width="1.2em" height="1.2em" />
<span>Back</span>
</button>
</template>
<div v-if="!entityDetails">
<p>Loading entity details...</p>
</div>
<template v-else>
<dl>
<dt>Type</dt>
<dd>
<router-link :to="{ name: 'Entities' }" class="entity-type-link">
{{ entityType }}
</router-link>
</dd>
<dt v-if="entityDetails.title">Title</dt>
<dd v-if="entityDetails.title">{{ entityDetails.title }}</dd>
<template v-if="entityDetails.fields">
<template v-for="(value, key) in entityDetails.fields" :key="key">
<dt>{{ key }}</dt>
<dd>{{ value }}</dd>
</template>
</template>
</dl>
<p v-if="!entityDetails.title && (!entityDetails.fields || Object.keys(entityDetails.fields).length === 0)">No details available for this entity.</p>
</template>
</Section>
<Section>
<template #title>
<span class="section-title-with-icon">
Entity Details:
<ActionIconGlyph
v-if="entityIcon"
class="entity-title-icon"
:glyph="entityIcon"
/>
<span v-if="entityDetails?.title">{{ entityDetails.title }}</span>
</span>
</template>
<template #toolbar>
<button
class="back-button"
@click="goBack"
>
<HugeiconsIcon
:icon="ArrowLeftIcon"
width="1.2em"
height="1.2em"
/>
<span>Back</span>
</button>
</template>
<div v-if="!entityDetails">
<p>Loading entity details...</p>
</div>
<template v-else>
<dl>
<dt>Type</dt>
<dd>
<router-link
:to="{ name: 'Entities' }"
class="entity-type-link"
>
{{ entityType }}
</router-link>
</dd>
<dt v-if="entityDetails.title">
Title
</dt>
<dd v-if="entityDetails.title">
{{ entityDetails.title }}
</dd>
<template v-if="entityDetails.fields">
<template
v-for="(value, key) in entityDetails.fields"
:key="key"
>
<dt>{{ key }}</dt>
<dd>{{ value }}</dd>
</template>
</template>
</dl>
<p v-if="!entityDetails.title && (!entityDetails.fields || Object.keys(entityDetails.fields).length === 0)">
No details available for this entity.
</p>
</template>
</Section>
<Section v-if="entityDetails" title="Dashboard Entity Directories">
<div v-if="filteredDirectories.length > 0" class="directories-section">
<ul class="directory-list">
<li v-for="(directory, idx) in filteredDirectories" :key="idx">
<router-link
:to="{
name: 'Dashboard',
params: {
title: directory,
entityType: entityType,
entityKey: entityKey
}
}">
{{ directory }}
</router-link>
</li>
</ul>
</div>
<p v-else>No directories found for this entity.
<a href="https://docs.olivetin.app/dashboards/entity-directories.html" target="_blank" rel="noopener noreferrer">Learn more</a>
</p>
</Section>
<Section
v-if="entityDetails"
title="Dashboard Entity Directories"
>
<div
v-if="filteredDirectories.length > 0"
class="directories-section"
>
<ul class="directory-list">
<li
v-for="(directory, idx) in filteredDirectories"
:key="idx"
>
<router-link
:to="{
name: 'Dashboard',
params: {
title: directory,
entityType: entityType,
entityKey: entityKey
}
}"
>
{{ directory }}
</router-link>
</li>
</ul>
</div>
<p v-else>
No directories found for this entity.
<a
href="https://docs.olivetin.app/dashboards/entity-directories.html"
target="_blank"
rel="noopener noreferrer"
>Learn more</a>
</p>
</Section>
<section v-if="entityDetails && relatedActions.length > 0" class="transparent">
<div class="dashboard-row">
<fieldset>
<legend class="visually-hidden">Related actions</legend>
<template v-for="(related, idx) in relatedActions" :key="related.action?.bindingId || idx">
<ActionButton
v-if="related.action"
:action-data="related.action"
:prefilled-arguments="related.prefilledArguments"
/>
</template>
</fieldset>
</div>
</section>
<section
v-if="entityDetails && relatedActions.length > 0"
class="transparent"
>
<div class="dashboard-row">
<fieldset>
<legend class="visually-hidden">
Related actions
</legend>
<template
v-for="(related, idx) in relatedActions"
:key="related.action?.bindingId || idx"
>
<ActionButton
v-if="related.action"
:action-data="related.action"
:prefilled-arguments="related.prefilledArguments"
/>
</template>
</fieldset>
</div>
</section>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { HugeiconsIcon } from '@hugeicons/vue'
import { ArrowLeftIcon } from '@hugeicons/core-free-icons'
import Section from 'picocrank/vue/components/Section.vue'
import ActionButton from '../ActionButton.vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { HugeiconsIcon } from '@hugeicons/vue'
import { ArrowLeftIcon } from '@hugeicons/core-free-icons'
import Section from 'picocrank/vue/components/Section.vue'
import ActionButton from '../ActionButton.vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
const router = useRouter()
const entityDetails = ref(null)
const router = useRouter()
const entityDetails = ref(null)
const props = defineProps({
entityType: String,
entityKey: String
})
const props = defineProps({
entityType: String,
entityKey: String
})
const filteredDirectories = computed(() => {
if (!entityDetails.value?.directories) {
return []
}
return entityDetails.value.directories.filter(d => d)
})
const filteredDirectories = computed(() => {
if (!entityDetails.value?.directories) {
return []
}
return entityDetails.value.directories.filter(d => d)
})
const relatedActions = computed(() => entityDetails.value?.relatedActions ?? [])
const relatedActions = computed(() => entityDetails.value?.relatedActions ?? [])
const entityIcon = computed(() => entityDetails.value?.icon ?? '')
const entityIcon = computed(() => entityDetails.value?.icon ?? '')
function goBack() {
router.push({ name: 'Entities' })
}
function goBack () {
router.push({ name: 'Entities' })
}
async function fetchEntityDetails() {
try {
const response = await window.client.getEntity({
type: props.entityType,
uniqueKey: props.entityKey
})
async function fetchEntityDetails () {
try {
const response = await window.client.getEntity({
type: props.entityType,
uniqueKey: props.entityKey
})
entityDetails.value = response
} catch (err) {
console.error('Failed to fetch entity details:', err)
window.showBigError('fetch-entity-details', 'getting entity details', err, false)
}
}
entityDetails.value = response
} catch (err) {
console.error('Failed to fetch entity details:', err)
window.showBigError('fetch-entity-details', 'getting entity details', err, false)
}
}
onMounted(() => {
onMounted(() => {
fetchEntityDetails()
})
})
</script>

View File

@ -9,87 +9,141 @@
class="action-details-title-link"
:title="titleTooltip"
>
<ActionIconGlyph class="action-title-icon" :glyph="icon" />
<LogActionTitle v-if="logEntry" :action-title="title" :justification="logEntry.justification" />
<ActionIconGlyph
class="action-title-icon"
:glyph="icon"
/>
<LogActionTitle
v-if="logEntry"
:action-title="title"
:justification="logEntry.justification"
/>
<span v-else>{{ title }}</span>
</router-link>
<template v-else>
<LogActionTitle v-if="logEntry" :action-title="title" :justification="logEntry.justification" />
<LogActionTitle
v-if="logEntry"
:action-title="title"
:justification="logEntry.justification"
/>
<span v-else>{{ title }}</span>
</template>
</span>
</template>
<template #toolbar>
<button
v-for="dashboard in backToDashboards"
:key="dashboard.path"
@click="goToDashboard(dashboard.path)"
:title="'Back to ' + dashboard.title"
class="button neutral"
>
<HugeiconsIcon :icon="DashboardSquare01Icon" />
{{ dashboard.title }}
</button>
<button v-if="backToDashboards.length === 0" @click="goBack" title="Go back" class="button neutral">
<HugeiconsIcon :icon="ArrowLeftIcon" />
Back
</button>
<button
v-for="dashboard in backToDashboards"
:key="dashboard.path"
:title="'Back to ' + dashboard.title"
class="button neutral"
@click="goToDashboard(dashboard.path)"
>
<HugeiconsIcon :icon="DashboardSquare01Icon" />
{{ dashboard.title }}
</button>
<button
v-if="backToDashboards.length === 0"
title="Go back"
class="button neutral"
@click="goBack"
>
<HugeiconsIcon :icon="ArrowLeftIcon" />
Back
</button>
</template>
<div v-if="logEntry" class = "flex-row">
<dl class = "fg1">
<dt>Duration</dt>
<dd><span v-html="duration"></span></dd>
<div
v-if="logEntry"
class="flex-row"
>
<dl class="fg1">
<dt>Duration</dt>
<dd><span v-html="duration" /></dd>
<dt>Status</dt>
<dd class="execution-dialog-status">
<ActionStatusDisplay :log-entry="logEntry" :link-queued-status="true" />
</dd>
</dl>
<dt>Status</dt>
<dd class="execution-dialog-status">
<ActionStatusDisplay
:log-entry="logEntry"
:link-queued-status="true"
/>
</dd>
</dl>
</div>
<div v-if="notFound" class="error-message padded-content">
<h3>Execution Not Found</h3>
<p>{{ errorMessage }}</p>
<p>The execution with ID <code>{{ executionTrackingId }}</code> could not be found.</p>
<router-link to="/logs">View all logs</router-link> or <router-link to="/">return to home</router-link>.
</div>
<div
v-if="notFound"
class="error-message padded-content"
>
<h3>Execution Not Found</h3>
<p>{{ errorMessage }}</p>
<p>The execution with ID <code>{{ executionTrackingId }}</code> could not be found.</p>
<router-link to="/logs">
View all logs
</router-link> or <router-link to="/">
return to home
</router-link>.
</div>
<div class="xterm-output-container">
<div class="xterm-overlay-toolbar">
<button type="button" class="xterm-overlay-button" @click="copyOutput" title="Copy to clipboard">
<button
type="button"
class="xterm-overlay-button"
title="Copy to clipboard"
@click="copyOutput"
>
<HugeiconsIcon :icon="Copy01Icon" />
</button>
<button type="button" class="xterm-overlay-button" @click="toggleSize" title="Toggle fullscreen">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="M3 3h6v2H6.462l4.843 4.843l-1.415 1.414L5 6.367V9H3zm0 18h6v-2H6.376l4.929-4.928l-1.415-1.414L5 17.548V15H3zm12 0h6v-6h-2v2.524l-4.867-4.866l-1.414 1.414L17.647 19H15zm6-18h-6v2h2.562l-4.843 4.843l1.414 1.414L19 6.39V9h2z" />
<button
type="button"
class="xterm-overlay-button"
title="Toggle fullscreen"
@click="toggleSize"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M3 3h6v2H6.462l4.843 4.843l-1.415 1.414L5 6.367V9H3zm0 18h6v-2H6.376l4.929-4.928l-1.415-1.414L5 17.548V15H3zm12 0h6v-6h-2v2.524l-4.867-4.866l-1.414 1.414L17.647 19H15zm6-18h-6v2h2.562l-4.843 4.843l1.414 1.414L19 6.39V9h2z"
/>
</svg>
</button>
</div>
<div ref="xtermOutput"></div>
<div ref="xtermOutput" />
</div>
<br />
<br>
<div class="flex-row g1 buttons padded-content">
<div class = "fg1" />
<div class="flex-row g1 buttons padded-content">
<div class="fg1" />
<button :disabled="!canRerun" @click="rerunAction" title="Rerun">
<HugeiconsIcon :icon="WorkoutRunIcon" />
Rerun
</button>
<button :disabled="!canKill" @click="killAction" title="Kill" id = "execution-dialog-kill-action">
<HugeiconsIcon :icon="Cancel02Icon" />
Kill
</button>
</div>
</Section>
<button
:disabled="!canRerun"
title="Rerun"
@click="rerunAction"
>
<HugeiconsIcon :icon="WorkoutRunIcon" />
Rerun
</button>
<button
id="execution-dialog-kill-action"
:disabled="!canKill"
title="Kill"
@click="killAction"
>
<HugeiconsIcon :icon="Cancel02Icon" />
Kill
</button>
</div>
</Section>
</template>
<script setup>
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
import LogActionTitle from '../components/LogActionTitle.vue'
@ -113,8 +167,8 @@ const xtermOutput = ref(null)
const props = defineProps({
executionTrackingId: {
type: String,
required: true
type: String,
required: true
}
})
@ -138,7 +192,7 @@ const errorMessage = ref('')
let executionTicker = null
let terminal = null
function initializeTerminal() {
function initializeTerminal () {
terminal = new OutputTerminal(executionTrackingId.value)
terminal.open(xtermOutput.value)
terminal.resize(80, 40)
@ -146,7 +200,7 @@ function initializeTerminal() {
window.terminal = terminal
}
function toggleSize() {
function toggleSize () {
if (!xtermOutput.value) {
return
}
@ -162,7 +216,7 @@ function toggleSize() {
}
}
async function copyOutput() {
async function copyOutput () {
const text = terminal?.getBufferAsString?.() || logEntry.value?.output || ''
if (!text) {
return
@ -175,7 +229,7 @@ async function copyOutput() {
}
}
async function reset() {
async function reset () {
executionSeconds.value = 0
executionTrackingId.value = 'notset'
hideBasics.value = false
@ -195,31 +249,31 @@ async function reset() {
errorMessage.value = ''
if (terminal) {
await terminal.reset()
terminal.fit()
await terminal.reset()
terminal.fit()
}
}
function show(actionButton) {
function show (actionButton) {
if (actionButton) {
icon.value = actionButton.glyph ?? ''
icon.value = actionButton.glyph ?? ''
}
canKill.value = true
// Clear existing ticker
if (executionTicker) {
clearInterval(executionTicker)
clearInterval(executionTicker)
}
executionSeconds.value = 0
executionTick()
executionTicker = setInterval(() => {
executionTick()
executionTick()
}, 1000)
}
async function rerunAction() {
async function rerunAction () {
const bindingId = logEntry.value?.bindingId
if (!logEntry.value || !bindingId) {
console.error('Cannot rerun: no action ID available')
@ -247,36 +301,36 @@ async function rerunAction() {
}
}
async function killAction() {
async function killAction () {
if (!executionTrackingId.value || executionTrackingId.value === 'notset') {
return
return
}
const killActionArgs = {
executionTrackingId: executionTrackingId.value
executionTrackingId: executionTrackingId.value
}
try {
await window.client.killAction(killActionArgs)
await window.client.killAction(killActionArgs)
} catch (err) {
console.error('Failed to kill action:', err)
console.error('Failed to kill action:', err)
}
}
function executionTick() {
function executionTick () {
executionSeconds.value++
updateDuration(null)
}
function hideEverythingApartFromOutput() {
function hideEverythingApartFromOutput () {
hideDetailsOnResult.value = true
hideBasics.value = true
hideDetailsOnResult.value = true
hideBasics.value = true
}
async function fetchExecutionResult(executionTrackingIdParam) {
console.log("fetchExecutionResult", executionTrackingIdParam)
async function fetchExecutionResult (executionTrackingIdParam) {
console.log('fetchExecutionResult', executionTrackingIdParam)
executionTrackingId.value = executionTrackingIdParam
notFound.value = false
@ -284,50 +338,49 @@ async function fetchExecutionResult(executionTrackingIdParam) {
backToDashboards.value = []
const executionStatusArgs = {
executionTrackingId: executionTrackingId.value
executionTrackingId: executionTrackingId.value
}
try {
const executionStatusResult = await window.client.executionStatus(executionStatusArgs)
const executionStatusResult = await window.client.executionStatus(executionStatusArgs)
await renderExecutionResult(executionStatusResult)
await renderExecutionResult(executionStatusResult)
} catch (err) {
// Check if it's a "not found" error (404 or similar)
if (err.status === 404 || err.code === 'NotFound' || err.message?.includes('not found')) {
// Check if it's a "not found" error (404 or similar)
if (err.status === 404 || err.code === 'NotFound' || err.message?.includes('not found')) {
notFound.value = true
errorMessage.value = err.message || 'The execution could not be found in the system.'
} else {
} else {
renderError(err)
}
throw err
}
throw err
}
}
function updateDuration(logEntryParam) {
function updateDuration (logEntryParam) {
logEntry.value = logEntryParam
if (logEntry.value == null) {
duration.value = executionSeconds.value + ' seconds'
duration.value = duration.value
duration.value = executionSeconds.value + ' seconds'
} else if (!logEntry.value.executionStarted) {
duration.value = logEntry.value.datetimeStarted + ' (request time). Not executed.'
duration.value = logEntry.value.datetimeStarted + ' (request time). Not executed.'
} else if (logEntry.value.executionStarted && !logEntry.value.executionFinished) {
duration.value = logEntry.value.datetimeStarted
duration.value = logEntry.value.datetimeStarted
} else {
let delta = ''
try {
let delta = ''
try {
delta = (new Date(logEntry.value.datetimeFinished) - new Date(logEntry.value.datetimeStarted)) / 1000
delta = new Intl.RelativeTimeFormat().format(delta, 'seconds').replace('in ', '').replace('ago', '')
} catch (e) {
} catch (e) {
console.warn('Failed to calculate delta', e)
}
duration.value = logEntry.value.datetimeStarted + ' &rarr; ' + logEntry.value.datetimeFinished
if (delta !== '') {
}
duration.value = logEntry.value.datetimeStarted + ' &rarr; ' + logEntry.value.datetimeFinished
if (delta !== '') {
duration.value += ' (' + delta + ')'
}
}
}
}
async function renderExecutionResult(res) {
async function renderExecutionResult (res) {
logEntry.value = res.logEntry
if (res.backToDashboards) {
backToDashboards.value = res.backToDashboards.slice(0, 3)
@ -335,12 +388,12 @@ async function renderExecutionResult(res) {
// Clear ticker
if (executionTicker) {
clearInterval(executionTicker)
clearInterval(executionTicker)
}
executionTicker = null
if (hideDetailsOnResult.value) {
hideDetails.value = true
hideDetails.value = true
}
executionTrackingId.value = res.logEntry.executionTrackingId
@ -355,41 +408,41 @@ async function renderExecutionResult(res) {
updateDuration(res.logEntry)
if (terminal) {
await terminal.reset()
await terminal.write(res.logEntry.output, () => {
await terminal.reset()
await terminal.write(res.logEntry.output, () => {
terminal.fit()
})
})
}
}
function renderError(err) {
function renderError (err) {
window.showBigError('execution-dlg-err', 'in the execution dialog', 'Failed to fetch execution result. ' + err, false)
}
function handleClose() {
function handleClose () {
if (executionTicker) {
clearInterval(executionTicker)
clearInterval(executionTicker)
}
executionTicker = null
}
function cleanup() {
function cleanup () {
if (executionTicker) {
clearInterval(executionTicker)
clearInterval(executionTicker)
}
executionTicker = null
if (terminal != null) {
terminal.close()
terminal.close()
}
terminal = null
}
function goBack() {
function goBack () {
router.back()
}
function goToDashboard(path) {
function goToDashboard (path) {
router.push(path)
}
@ -409,16 +462,15 @@ onMounted(() => {
fetchExecutionResult(props.executionTrackingId)
watch(
() => buttonResults[props.executionTrackingId],
(newResult, oldResult) => {
() => buttonResults[props.executionTrackingId],
(newResult, oldResult) => {
if (newResult) {
renderExecutionResult({
renderExecutionResult({
logEntry: newResult
})
})
}
}
}
)
})
onBeforeUnmount(() => {

View File

@ -1,33 +1,78 @@
<template>
<Section title="Login to OliveTin" class="small">
<Section
title="Login to OliveTin"
class="small"
>
<div class="login-form">
<div v-if="!hasOAuth && !hasLocalLogin" class="login-disabled">
<div
v-if="!hasOAuth && !hasLocalLogin"
class="login-disabled"
>
<span>This server is not configured with either OAuth, or local users, so you cannot login.</span>
</div>
<div v-if="hasOAuth" class="login-oauth2">
<div
v-if="hasOAuth"
class="login-oauth2"
>
<h3>OAuth Login</h3>
<div class="oauth-providers">
<button v-for="provider in oauthProviders" :key="provider.key" class="oauth-button"
@click="loginWithOAuth(provider)">
<span v-if="provider.icon" class="provider-icon" v-html="provider.icon"></span>
<button
v-for="provider in oauthProviders"
:key="provider.key"
class="oauth-button"
@click="loginWithOAuth(provider)"
>
<span
v-if="provider.icon"
class="provider-icon"
v-html="provider.icon"
/>
<span class="provider-name">Login with {{ provider.title }}</span>
</button>
</div>
</div>
<div v-if="hasLocalLogin" class="login-local">
<div
v-if="hasLocalLogin"
class="login-local"
>
<h3>Local Login</h3>
<form @submit.prevent="handleLocalLogin" class="local-login-form">
<div v-if="loginError" class="bad">
<form
class="local-login-form"
@submit.prevent="handleLocalLogin"
>
<div
v-if="loginError"
class="bad"
>
{{ loginError }}
</div>
<input id="username" v-model="username" type="text" name="username" autocomplete="username" required placeholder="Username" />
<input id="password" v-model="password" type="password" name="password" autocomplete="current-password" placeholder="Password"
required />
<input
id="username"
v-model="username"
type="text"
name="username"
autocomplete="username"
required
placeholder="Username"
>
<input
id="password"
v-model="password"
type="password"
name="password"
autocomplete="current-password"
placeholder="Password"
required
>
<button type="submit" :disabled="loading" class="login-button">
<button
type="submit"
:disabled="loading"
class="login-button"
>
{{ loading ? 'Logging in...' : 'Login' }}
</button>
</form>
@ -51,7 +96,7 @@ const hasOAuth = ref(false)
const hasLocalLogin = ref(false)
const oauthProviders = ref([])
function loadLoginOptions() {
function loadLoginOptions () {
// Use the init response data that was loaded in App.vue
if (window.initResponse) {
hasOAuth.value = window.initResponse.oAuth2Providers && window.initResponse.oAuth2Providers.length > 0
@ -65,7 +110,7 @@ function loadLoginOptions() {
}
}
async function handleLocalLogin() {
async function handleLocalLogin () {
loading.value = true
loginError.value = ''
@ -105,7 +150,7 @@ async function handleLocalLogin() {
}
}
function loginWithOAuth(provider) {
function loginWithOAuth (provider) {
if (!provider.key) {
console.error('OAuth provider missing key:', provider)
return
@ -119,7 +164,7 @@ onMounted(() => {
loadLoginOptions()
// Also watch for when init response becomes available
const stopWatcher = watch(() => window.initResponse, () => {
watch(() => window.initResponse, () => {
loadLoginOptions()
}, { immediate: true })
})

View File

@ -1,9 +1,23 @@
<template>
<Section :title="t('logs.calendar-title')" :padding="false">
<Section
:title="t('logs.calendar-title')"
:padding="false"
>
<template #toolbar>
<router-link to="/logs" class="button neutral">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20z"/>
<router-link
to="/logs"
class="button neutral"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20z"
/>
</svg>
{{ t('logs.back-to-list') }}
</router-link>
@ -62,8 +76,8 @@ const calendarEvents = computed(() => {
id: log.executionTrackingId,
title: log.actionTitle || 'Untitled Action',
date: startDate,
startDate: startDate,
endDate: endDate,
startDate,
endDate,
actionIcon: log.actionIcon,
user: log.user,
tags: log.tags,
@ -72,7 +86,7 @@ const calendarEvents = computed(() => {
})
})
async function fetchLogs() {
async function fetchLogs () {
loading.value = true
error.value = null
@ -82,11 +96,10 @@ async function fetchLogs() {
const allLogs = []
let startOffset = BigInt(0)
const maxLogs = 10000 // Reasonable limit to prevent excessive API calls
const pageSize = 100 // Typical page size, will be updated from API response
while (allLogs.length < maxLogs) {
const args = {
"startOffset": startOffset,
startOffset
}
const response = await window.client.getLogs(args)
@ -120,14 +133,14 @@ async function fetchLogs() {
}
}
function handleEventClick(event) {
function handleEventClick (event) {
// Navigate to the execution view when clicking on a calendar event
if (event.id) {
router.push(`/logs/${event.id}`)
}
}
function handleDayClick(date) {
function handleDayClick (date) {
// Navigate to logs list filtered by the selected date
// Format date as YYYY-MM-DD using local date components to avoid timezone issues
const year = date.getFullYear()
@ -137,7 +150,7 @@ function handleDayClick(date) {
router.push({ path: '/logs', query: { date: dateString } })
}
function handleMonthChange(month, year) {
function handleMonthChange (month, year) {
currentMonthIndex.value = month
currentYear.value = year
// Optionally fetch logs for the new month if needed

View File

@ -1,116 +1,212 @@
<template>
<Section :title="t('logs.title')" :padding="false">
<template #toolbar>
<router-link to="/logs/queue" class="button neutral">
{{ t('logs.queue') }}
</router-link>
<router-link to="/logs/calendar" class="button neutral">
{{ t('logs.calendar') }}
</router-link>
<label class="input-with-icons">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14" />
</svg>
<input
:placeholder="t('logs.filter-placeholder')"
v-model="searchText"
list="logs-filter-suggestions"
:aria-invalid="filterError ? 'true' : 'false'"
<Section
:title="t('logs.title')"
:padding="false"
>
<template #toolbar>
<router-link
to="/logs/queue"
class="button neutral"
>
{{ t('logs.queue') }}
</router-link>
<router-link
to="/logs/calendar"
class="button neutral"
>
{{ t('logs.calendar') }}
</router-link>
<label class="input-with-icons">
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14"
/>
<datalist id="logs-filter-suggestions">
<option v-for="suggestion in filterSuggestions" :key="suggestion" :value="suggestion" />
</datalist>
<button :title="t('logs.clear-filter')" :disabled="!searchText" @click="clearSearch">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
</svg>
</button>
</label>
</template>
<div class="padding logs-intro">
<p>{{ t('logs.page-description') }}</p>
<details class="filter-help">
<summary>{{ t('logs.filter-help-title') }}</summary>
<p>{{ t('logs.filter-help-intro') }}</p>
<p>{{ t('logs.filter-help-fields') }}</p>
<p><code>{{ t('logs.filter-help-examples') }}</code></p>
</details>
<p v-if="filterError" class="filter-error" role="alert">{{ filterError }}</p>
</div>
<div v-show="logs.length > 0">
<table class="logs-table row-hover">
<thead>
<tr>
<th>
<div class="timestamp-header">
<span>{{ t('logs.timestamp') }}</span>
<span v-if="selectedDate" class="date-filter-indicator">
<span class="date-filter-text">{{ formatDateFilter(selectedDate) }}</span>
<button :title="t('logs.clear-date-filter')" @click="clearDateFilter" class="clear-date-button">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor"
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" />
</svg>
</button>
</span>
</div>
</th>
<th>{{ t('logs.action') }}</th>
<th>{{ t('logs.metadata') }}</th>
<th>{{ t('logs.status') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="log in logs" :key="log.executionTrackingId" class="log-row" :title="log.actionTitle">
<td class="timestamp">{{ formatTimestamp(log.datetimeStarted) }}</td>
<td>
<ActionIconGlyph class="icon" :glyph="log.actionIcon" />
<router-link :to="`/logs/${log.executionTrackingId}`">
<LogActionTitle :action-title="log.actionTitle" :justification="log.justification" />
</router-link>
</td>
<td class="tags">
<span class="annotation">
<span class="annotation-key">User:</span>
<span class="annotation-val">{{ log.user }}</span>
</span>
<span v-if="log.tags && log.tags.length > 0" class="tag-list">
<span v-for="tag in log.tags" :key="tag" class="tag">{{ tag }}</span>
</span>
</td>
<td class="exit-code">
<ActionStatusDisplay :logEntry="log" />
</td>
</tr>
</tbody>
</table>
<Pagination :pageSize="pageSize" :total="totalCount" :currentPage="currentPage" :page="currentPage" @page-change="handlePageChange" class = "padding"
@page-size-change="handlePageSizeChange" itemTitle="execution logs" />
</div>
<div v-show="logs.length === 0 && !loading && searchText && !filterError" class="empty-state padding">
<p>{{ t('logs.no-logs-for-filter') }}</p>
<button @click="clearSearch" class="button neutral">
{{ t('logs.clear-filter') }}
</svg>
<input
v-model="searchText"
:placeholder="t('logs.filter-placeholder')"
list="logs-filter-suggestions"
:aria-invalid="filterError ? 'true' : 'false'"
>
<datalist id="logs-filter-suggestions">
<option
v-for="suggestion in filterSuggestions"
:key="suggestion"
:value="suggestion"
/>
</datalist>
<button
:title="t('logs.clear-filter')"
:disabled="!searchText"
@click="clearSearch"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"
/>
</svg>
</button>
</div>
</label>
</template>
<div v-show="selectedDate && logs.length === 0 && !loading && !searchText" class="empty-state padding">
<p>{{ t('logs.no-logs-to-display') }} {{ formatDateFilter(selectedDate) }}.</p>
<button @click="clearDateFilter" class="button neutral">
{{ t('logs.clear-date-filter') }}
</button>
</div>
<div class="padding logs-intro">
<p>{{ t('logs.page-description') }}</p>
<details class="filter-help">
<summary>{{ t('logs.filter-help-title') }}</summary>
<p>{{ t('logs.filter-help-intro') }}</p>
<p>{{ t('logs.filter-help-fields') }}</p>
<p><code>{{ t('logs.filter-help-examples') }}</code></p>
</details>
<p
v-if="filterError"
class="filter-error"
role="alert"
>
{{ filterError }}
</p>
</div>
<div v-show="logs.length === 0 && !loading && !selectedDate && !searchText" class="empty-state padding">
<p>{{ t('logs.no-logs-to-display') }}</p>
<router-link to="/">{{ t('return-to-index') }}</router-link>
</div>
<div v-show="logs.length > 0">
<table class="logs-table row-hover">
<thead>
<tr>
<th>
<div class="timestamp-header">
<span>{{ t('logs.timestamp') }}</span>
<span
v-if="selectedDate"
class="date-filter-indicator"
>
<span class="date-filter-text">{{ formatDateFilter(selectedDate) }}</span>
<button
:title="t('logs.clear-date-filter')"
class="clear-date-button"
@click="clearDateFilter"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z"
/>
</svg>
</button>
</span>
</div>
</th>
<th>{{ t('logs.action') }}</th>
<th>{{ t('logs.metadata') }}</th>
<th>{{ t('logs.status') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="log in logs"
:key="log.executionTrackingId"
class="log-row"
:title="log.actionTitle"
>
<td class="timestamp">
{{ formatTimestamp(log.datetimeStarted) }}
</td>
<td>
<ActionIconGlyph
class="icon"
:glyph="log.actionIcon"
/>
<router-link :to="`/logs/${log.executionTrackingId}`">
<LogActionTitle
:action-title="log.actionTitle"
:justification="log.justification"
/>
</router-link>
</td>
<td class="tags">
<span class="annotation">
<span class="annotation-key">User:</span>
<span class="annotation-val">{{ log.user }}</span>
</span>
<span
v-if="log.tags && log.tags.length > 0"
class="tag-list"
>
<span
v-for="tag in log.tags"
:key="tag"
class="tag"
>{{ tag }}</span>
</span>
</td>
<td class="exit-code">
<ActionStatusDisplay :log-entry="log" />
</td>
</tr>
</tbody>
</table>
<Pagination
:page-size="pageSize"
:total="totalCount"
:current-page="currentPage"
:page="currentPage"
class="padding"
item-title="execution logs"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</div>
<div
v-show="logs.length === 0 && !loading && searchText && !filterError"
class="empty-state padding"
>
<p>{{ t('logs.no-logs-for-filter') }}</p>
<button
class="button neutral"
@click="clearSearch"
>
{{ t('logs.clear-filter') }}
</button>
</div>
<div
v-show="selectedDate && logs.length === 0 && !loading && !searchText"
class="empty-state padding"
>
<p>{{ t('logs.no-logs-to-display') }} {{ formatDateFilter(selectedDate) }}.</p>
<button
class="button neutral"
@click="clearDateFilter"
>
{{ t('logs.clear-date-filter') }}
</button>
</div>
<div
v-show="logs.length === 0 && !loading && !selectedDate && !searchText"
class="empty-state padding"
>
<p>{{ t('logs.no-logs-to-display') }}</p>
<router-link to="/">
{{ t('return-to-index') }}
</router-link>
</div>
</Section>
</template>
@ -150,7 +246,7 @@ const filterSuggestions = [
const { t } = useI18n()
function updateDateFromRoute() {
function updateDateFromRoute () {
const dateParam = route.query.date
if (dateParam) {
selectedDate.value = dateParam
@ -169,7 +265,7 @@ watch(searchText, () => {
scheduleFetchLogs()
})
async function fetchLogs() {
async function fetchLogs () {
loading.value = true
filterError.value = ''
try {
@ -206,7 +302,7 @@ async function fetchLogs() {
}
}
function scheduleFetchLogs() {
function scheduleFetchLogs () {
if (fetchTimer) {
clearTimeout(fetchTimer)
}
@ -215,18 +311,18 @@ function scheduleFetchLogs() {
}, 400)
}
function clearSearch() {
function clearSearch () {
searchText.value = ''
}
function clearDateFilter() {
function clearDateFilter () {
selectedDate.value = null
const query = { ...route.query }
delete query.date
router.push({ path: route.path, query })
}
function formatDateFilter(dateString) {
function formatDateFilter (dateString) {
try {
const date = new Date(dateString + 'T00:00:00')
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
@ -235,7 +331,7 @@ function formatDateFilter(dateString) {
}
}
function formatTimestamp(timestamp) {
function formatTimestamp (timestamp) {
if (!timestamp) return 'Unknown'
try {
const date = new Date(timestamp)
@ -245,18 +341,18 @@ function formatTimestamp(timestamp) {
}
}
function handlePageChange(page) {
function handlePageChange (page) {
currentPage.value = page
fetchLogs()
}
function handlePageSizeChange(newPageSize) {
function handlePageSizeChange (newPageSize) {
pageSize.value = newPageSize
currentPage.value = 1
fetchLogs()
}
function onExecutionEvent(evt) {
function onExecutionEvent (evt) {
const logEntry = getExecutionLogEntry(evt)
if (!logEntry) {
return

View File

@ -1,19 +1,40 @@
<template>
<Section :title="t('logs.queue-title')" :padding="false">
<Section
:title="t('logs.queue-title')"
:padding="false"
>
<template #toolbar>
<router-link to="/logs" class="button neutral">
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 24 24">
<path fill="currentColor" d="M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20z"/>
<router-link
to="/logs"
class="button neutral"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M20 11H7.83l5.59-5.59L12 4l-8 8l8 8l1.41-1.41L7.83 13H20z"
/>
</svg>
{{ t('logs.back-to-list') }}
</router-link>
</template>
<p class="padding">{{ t('logs.queue-page-description') }}</p>
<p class="padding">
{{ t('logs.queue-page-description') }}
</p>
<div v-if="groups.length === 0 && !loading" class="empty-state padding">
<div
v-if="groups.length === 0 && !loading"
class="empty-state padding"
>
<p>{{ t('logs.queue-empty') }}</p>
<router-link to="/logs">{{ t('logs.back-to-list') }}</router-link>
<router-link to="/logs">
{{ t('logs.back-to-list') }}
</router-link>
</div>
</Section>
@ -24,7 +45,10 @@
>
<div class="section-header flex-row">
<div class="fg1 queue-action-group-heading">
<ActionIconGlyph class="icon" :glyph="actionGroup.icon" />
<ActionIconGlyph
class="icon"
:glyph="actionGroup.icon"
/>
<h2>{{ displayActionGroupName(actionGroup.name) }}</h2>
</div>
<ActionGroupLimitsLabel
@ -44,23 +68,34 @@
</tr>
</thead>
<tbody>
<template v-for="action in actionGroup.actions" :key="`${actionGroup.name}:${action.bindingId}`">
<template
v-for="action in actionGroup.actions"
:key="`${actionGroup.name}:${action.bindingId}`"
>
<tr
v-for="(entry, index) in action.entries"
:key="entry.executionTrackingId"
class="log-row"
:title="action.actionTitle"
>
<td class="timestamp">{{ formatTimestamp(entry.datetimeStarted) }}</td>
<td class="timestamp">
{{ formatTimestamp(entry.datetimeStarted) }}
</td>
<td>
<ActionIconGlyph class="icon" :glyph="action.actionIcon" />
<ActionIconGlyph
class="icon"
:glyph="action.actionIcon"
/>
<router-link :to="`/logs/${entry.executionTrackingId}`">
<LogActionTitle
:action-title="action.actionTitle"
:justification="entry.justification"
/>
</router-link>
<span v-if="action.entityPrefix" class="queue-entity annotation">
<span
v-if="action.entityPrefix"
class="queue-entity annotation"
>
{{ action.entityPrefix }}
</span>
</td>
@ -69,15 +104,28 @@
<span class="annotation-key">User:</span>
<span class="annotation-val">{{ entry.user }}</span>
</span>
<span v-if="entry.tags && entry.tags.length > 0" class="tag-list">
<span v-for="tag in entry.tags" :key="tag" class="tag">{{ tag }}</span>
<span
v-if="entry.tags && entry.tags.length > 0"
class="tag-list"
>
<span
v-for="tag in entry.tags"
:key="tag"
class="tag"
>{{ tag }}</span>
</span>
</td>
<td class="exit-code">
<span v-if="!entry.executionFinished" class="queue-position">
<span
v-if="!entry.executionFinished"
class="queue-position"
>
{{ t('logs.queue-position', { position: index + 1 }) }}
</span>
<ActionStatusDisplay :logEntry="entry" link-queued-status />
<ActionStatusDisplay
:log-entry="entry"
link-queued-status
/>
</td>
</tr>
</template>

View File

@ -6,10 +6,16 @@
<h2>Page Not Found</h2>
<div class="actions">
<button class = "button good" @click="goToHome">
<button
class="button good"
@click="goToHome"
>
Go to Home
</button>
<button class="button neutral" @click="goBack">
<button
class="button neutral"
@click="goBack"
>
Go Back
</button>
</div>
@ -22,10 +28,10 @@
export default {
name: 'NotFoundView',
methods: {
goBack() {
goBack () {
this.$router.go(-1)
},
goToHome() {
goToHome () {
this.$router.push('/')
}
}

View File

@ -1,27 +1,58 @@
<template>
<Section title="User Information" class="small">
<div v-if="!isLoggedIn" class="user-not-logged-in">
<Section
title="User Information"
class="small"
>
<div
v-if="!isLoggedIn"
class="user-not-logged-in"
>
<p>You are not currently logged in.</p>
<p>To access user settings and logout, please <router-link to="/login">log in</router-link>.</p>
<p>
To access user settings and logout, please <router-link to="/login">
log in
</router-link>.
</p>
</div>
<div v-else class="user-control-panel">
<div
v-else
class="user-control-panel"
>
<dl class="user-info">
<dt>Username</dt>
<dd>{{ username }}</dd>
<dt v-if="userProvider !== 'system'">Provider</dt>
<dd v-if="userProvider !== 'system'">{{ userProvider }}</dd>
<dt v-if="usergroup">Group</dt>
<dd v-if="usergroup">{{ usergroup }}</dd>
<dt v-if="acls && acls.length > 0">Matched ACLs</dt>
<dt v-if="userProvider !== 'system'">
Provider
</dt>
<dd v-if="userProvider !== 'system'">
{{ userProvider }}
</dd>
<dt v-if="usergroup">
Group
</dt>
<dd v-if="usergroup">
{{ usergroup }}
</dd>
<dt v-if="acls && acls.length > 0">
Matched ACLs
</dt>
<dd v-if="acls && acls.length > 0">
<span class="acl-tag" v-for="(acl, idx) in acls" :key="`acl-${idx}`">{{ acl }}</span>
<span
v-for="(acl, idx) in acls"
:key="`acl-${idx}`"
class="acl-tag"
>{{ acl }}</span>
</dd>
</dl>
<div class="user-actions">
<div class="action-buttons">
<button @click="handleLogout" class="button bad" :disabled="loggingOut">
<button
class="button bad"
:disabled="loggingOut"
@click="handleLogout"
>
{{ loggingOut ? 'Logging out...' : 'Logout' }}
</button>
</div>
@ -31,7 +62,7 @@
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import Section from 'picocrank/vue/components/Section.vue'
@ -44,7 +75,7 @@ const usergroup = ref('')
const loggingOut = ref(false)
const acls = ref([])
function updateUserInfo() {
function updateUserInfo () {
if (window.initResponse) {
isLoggedIn.value = window.initResponse.authenticatedUser !== '' && window.initResponse.authenticatedUser !== 'guest'
username.value = window.initResponse.authenticatedUser
@ -53,7 +84,7 @@ function updateUserInfo() {
}
}
async function fetchWhoAmI() {
async function fetchWhoAmI () {
try {
const res = await window.client.whoAmI({})
acls.value = res.acls || []
@ -67,7 +98,7 @@ async function fetchWhoAmI() {
}
}
async function handleLogout() {
async function handleLogout () {
loggingOut.value = true
try {
@ -102,13 +133,12 @@ async function handleLogout() {
}
}
let watchInterval = null
const watchInterval = null
onMounted(() => {
updateUserInfo()
fetchWhoAmI()
})
})
onUnmounted(() => {
if (watchInterval) {

View File

@ -13,7 +13,7 @@
},
"devDependencies": {
"chai": "^6.2.2",
"eslint": "^10.5.0",
"eslint": "^10.6.0",
"mocha": "^11.7.6",
"selenium-webdriver": "^4.45.0"
}
@ -783,9 +783,9 @@
}
},
"node_modules/eslint": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz",
"integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==",
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.6.0.tgz",
"integrity": "sha512-6lVbcqSodALYo+4ELD0heG6lFiFxnLMuLkiMi2qV8LMp54N8tE8FT1GMH+ev4Ti00nFjNze2+Su6DsV5OQW3Dg==",
"dev": true,
"license": "MIT",
"workspaces": [

View File

@ -12,7 +12,7 @@
"license": "AGPL-3.0-only",
"devDependencies": {
"chai": "^6.2.2",
"eslint": "^10.5.0",
"eslint": "^10.6.0",
"mocha": "^11.7.6",
"selenium-webdriver": "^4.45.0"
},

View File

@ -1,11 +1,11 @@
module github.com/OliveTin/OliveTin/langtool
go 1.24.0
go 1.25.0
require (
github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac
github.com/jamesread/golure v0.0.0-20260510214136-6ef80e0ce8da
github.com/sirupsen/logrus v1.9.4
gopkg.in/yaml.v3 v3.0.1
)
require golang.org/x/sys v0.40.0 // indirect
require golang.org/x/sys v0.47.0 // indirect

View File

@ -1,28 +1,16 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c h1:v8gN2xXFQjkF0PsoGSqDviRNmPHcBsvl6rMSbvXz1sM=
github.com/jamesread/golure v0.0.0-20250919212919-976d085a100c/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac h1:JQ6AC9tf2xhwTxMY9nuIeOPM7Cj0BDeCNgKPrNgQvtQ=
github.com/jamesread/golure v0.0.0-20260104005024-ad0d6ec8c0ac/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
github.com/jamesread/golure v0.0.0-20260510214136-6ef80e0ce8da h1:hYsJqujd3A4Xtp9swe2d6Y6ij2ecd16E+i2oHAH/xaA=
github.com/jamesread/golure v0.0.0-20260510214136-6ef80e0ce8da/go.mod h1:BZ/CMtZJJ4LNEBDSjGfafTJMjlDPIA9FS16+reN9NUE=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@ -46,7 +46,7 @@ TOOL_GROUPS: list[tuple[str, frozenset[str]]] = [
),
(
"Go codestyle (install via: make go-tools)",
frozenset({"gocyclo", "gocritic"}),
frozenset({"golangci-lint"}),
),
(
"Protocol buffers (install via: make -C service go-tools-all)",

25
service/.golangci.yml Normal file
View File

@ -0,0 +1,25 @@
version: "2"
run:
timeout: 5m
tests: true
linters:
default: none
enable:
- errcheck
- gocritic
- gocyclo
- ineffassign
- misspell
- staticcheck
- unconvert
- unused
linters-settings:
gocyclo:
min-complexity: 5
issues:
exclude-dirs:
- gen

View File

@ -30,8 +30,7 @@ compile: compile-armhf compile-x64-lin compile-x64-win
codestyle: go-tools
go fmt ./...
go vet ./...
gocyclo -over 4 internal
gocritic check ./...
golangci-lint run ./...
test: unittests
@ -54,13 +53,11 @@ find-flakey-tests-inf:
go run ./scripts/find-flakey-tests-inf
go-tools:
go install "github.com/fzipp/gocyclo/cmd/gocyclo"
go install "github.com/go-critic/go-critic/cmd/gocritic"
go install "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest"
.PHONY: unittests unittests-fast find-flakey-tests find-flakey-tests-inf
go-tools-all:
go install "github.com/bufbuild/buf/cmd/buf"
go install "github.com/fzipp/gocyclo/cmd/gocyclo"
go install "github.com/go-critic/go-critic/cmd/gocritic"
go install "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest"
go install "google.golang.org/protobuf/cmd/protoc-gen-go"

View File

@ -1,6 +1,6 @@
module github.com/OliveTin/OliveTin
go 1.25.10
go 1.26
exclude google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884
@ -10,36 +10,39 @@ require (
github.com/MicahParks/keyfunc/v3 v3.8.0
github.com/PaesslerAG/jsonpath v0.1.1
github.com/alexedwards/argon2id v1.0.0
github.com/bufbuild/buf v1.70.0
github.com/bufbuild/buf v1.71.0
github.com/expr-lang/expr v1.17.8
github.com/fsnotify/fsnotify v1.10.1
github.com/fzipp/gocyclo v0.6.0
github.com/go-critic/go-critic v0.14.3
github.com/go-viper/mapstructure/v2 v2.5.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/golangci/golangci-lint/v2 v2.12.2
github.com/google/uuid v1.6.0
github.com/jamesread/golure v0.0.0-20260510214136-6ef80e0ce8da
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env v1.1.0
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/providers/rawbytes v1.0.0
github.com/knadh/koanf/v2 v2.3.4
github.com/knadh/koanf/v2 v2.3.5
github.com/prometheus/client_golang v1.23.2
github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.9.4
github.com/stretchr/testify v1.11.1
go.akshayshah.org/connectproto v0.6.0
golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a
golang.org/x/exp v0.0.0-20260611194520-c48552f49976
golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.45.0
golang.org/x/sys v0.47.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
require (
buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.11-20250718181942-e35f9b667443.1 // indirect
4d63.com/gocheckcompilerdirectives v1.3.0 // indirect
4d63.com/gochecknoglobals v0.2.2 // indirect
buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.11-20260626152828-968bf0468096.1 // indirect
buf.build/gen/go/bufbuild/protodescriptor/protocolbuffers/go v1.36.11-20250109164928-1da0de137947.1 // indirect
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260415201107-50325440f8f2.1 // indirect
buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260507063250-43b0c5a6cd08.1 // indirect
buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260507063250-43b0c5a6cd08.1 // indirect
buf.build/gen/go/bufbuild/registry/connectrpc/go v1.20.0-20260629211719-be09125d9193.1 // indirect
buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260629211719-be09125d9193.1 // indirect
buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1 // indirect
buf.build/go/app v0.2.1-0.20260407195847-833f8f978cda // indirect
buf.build/go/bufplugin v0.10.0 // indirect
@ -50,33 +53,86 @@ require (
buf.build/go/spdx v0.2.0 // indirect
buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect
cel.dev/expr v0.25.2 // indirect
charm.land/lipgloss/v2 v2.0.3 // indirect
codeberg.org/chavacava/garif v0.2.0 // indirect
codeberg.org/polyfloyd/go-errorlint v1.9.0 // indirect
connectrpc.com/otelconnect v0.9.0 // indirect
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
dev.gaijin.team/go/exhaustruct/v4 v4.0.0 // indirect
dev.gaijin.team/go/golib v0.6.0 // indirect
github.com/4meepo/tagalign v1.4.3 // indirect
github.com/Abirdcfly/dupword v0.1.7 // indirect
github.com/AdminBenni/iota-mixing v1.0.0 // indirect
github.com/AlwxSin/noinlineerr v1.0.5 // indirect
github.com/Antonboom/errname v1.1.1 // indirect
github.com/Antonboom/nilnil v1.1.1 // indirect
github.com/Antonboom/testifylint v1.6.4 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/ClickHouse/clickhouse-go-linter v1.2.0 // indirect
github.com/Djarvur/go-err113 v0.1.1 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/MicahParks/jwkset v0.11.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/MirrexOne/unqueryvet v1.5.4 // indirect
github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect
github.com/PaesslerAG/gval v1.2.4 // indirect
github.com/alecthomas/chroma/v2 v2.24.1 // indirect
github.com/alecthomas/go-check-sumtype v0.3.1 // indirect
github.com/alexkohler/nakedret/v2 v2.0.6 // indirect
github.com/alexkohler/prealloc v1.1.0 // indirect
github.com/alfatraining/structtag v1.0.0 // indirect
github.com/alingse/asasalint v0.0.11 // indirect
github.com/alingse/nilnesserr v0.2.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/ashanbrown/forbidigo/v2 v2.3.1 // indirect
github.com/ashanbrown/makezero/v2 v2.2.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bufbuild/protocompile v0.14.2-0.20260522222248-64e6ad034132 // indirect
github.com/bkielbasa/cyclop v1.2.3 // indirect
github.com/blizzy78/varnamelen v0.8.0 // indirect
github.com/bombsimon/wsl/v4 v4.7.0 // indirect
github.com/bombsimon/wsl/v5 v5.8.0 // indirect
github.com/breml/bidichk v0.3.3 // indirect
github.com/breml/errchkjson v0.4.1 // indirect
github.com/bufbuild/protocompile v0.14.2-0.20260605203730-cd7c3c124e10 // indirect
github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b // indirect
github.com/butuzov/ireturn v0.4.1 // indirect
github.com/butuzov/mirror v1.3.0 // indirect
github.com/catenacyber/perfsprint v0.10.1 // indirect
github.com/ccojocar/zxcvbn-go v1.0.4 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charithe/durationcheck v0.0.11 // indirect
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20251205161215-1948445e3318 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.2.2 // indirect
github.com/ckaznocha/intrange v0.3.1 // indirect
github.com/cli/browser v1.3.0 // indirect
github.com/clipperhouse/displaywidth v0.11.0 // indirect
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/cristalhq/acmd v0.12.0 // indirect
github.com/curioswitch/go-reassign v0.3.0 // indirect
github.com/daixiang0/gci v0.13.7 // indirect
github.com/dave/dst v0.27.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/denis-tingaikin/go-header v0.5.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/cli v29.5.2+incompatible // indirect
github.com/docker/distribution v2.8.3+incompatible // indirect
github.com/docker/docker v28.5.2+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.7 // indirect
github.com/dlclark/regexp2 v1.12.0 // indirect
github.com/docker/cli v29.6.1+incompatible // indirect
github.com/docker/docker-credential-helpers v0.9.8 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/expr-lang/expr v1.17.8 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-chi/chi/v5 v5.2.5 // indirect
github.com/ettle/strcase v0.2.0 // indirect
github.com/fatih/color v1.19.0 // indirect
github.com/fatih/structtag v1.2.0 // indirect
github.com/felixge/httpsnoop v1.1.0 // indirect
github.com/firefart/nonamedreturns v1.0.6 // indirect
github.com/fzipp/gocyclo v0.6.0 // indirect
github.com/ghostiam/protogetter v0.3.20 // indirect
github.com/go-critic/go-critic v0.14.4 // indirect
github.com/go-json-experiment/json v0.0.0-20260623181947-01eb4420fa68 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-toolsmith/astcast v1.1.0 // indirect
@ -84,83 +140,175 @@ require (
github.com/go-toolsmith/astequal v1.2.0 // indirect
github.com/go-toolsmith/astfmt v1.1.0 // indirect
github.com/go-toolsmith/astp v1.1.0 // indirect
github.com/go-toolsmith/pkgload v1.2.2 // indirect
github.com/go-toolsmith/strparse v1.1.0 // indirect
github.com/go-toolsmith/typep v1.1.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/godoc-lint/godoc-lint v0.11.2 // indirect
github.com/gofrs/flock v0.13.0 // indirect
github.com/google/cel-go v0.28.1 // indirect
github.com/golangci/asciicheck v0.5.0 // indirect
github.com/golangci/dupl v0.0.0-20260401084720-c99c5cf5c202 // indirect
github.com/golangci/go-printf-func-name v0.1.1 // indirect
github.com/golangci/gofmt v0.0.0-20250106114630-d62b90e6713d // indirect
github.com/golangci/golines v0.15.0 // indirect
github.com/golangci/misspell v0.8.0 // indirect
github.com/golangci/plugin-module-register v0.1.2 // indirect
github.com/golangci/revgrep v0.8.0 // indirect
github.com/golangci/rowserrcheck v0.0.0-20260419091836-c5f79b8a11ba // indirect
github.com/golangci/swaggoswag v0.0.0-20250504205917-77f2aca3143e // indirect
github.com/golangci/unconvert v0.0.0-20250410112200-a129a6e6413e // indirect
github.com/google/cel-go v0.29.2 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-containerregistry v0.21.6 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/google/go-containerregistry v0.21.7 // indirect
github.com/gordonklaus/ineffassign v0.2.0 // indirect
github.com/gostaticanalysis/analysisutil v0.7.1 // indirect
github.com/gostaticanalysis/comment v1.5.0 // indirect
github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect
github.com/gostaticanalysis/nilerr v0.1.2 // indirect
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
github.com/hashicorp/go-version v1.9.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hexops/gotextdiff v1.0.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jdx/go-netrc v1.0.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/jgautheron/goconst v1.10.0 // indirect
github.com/jjti/go-spancheck v0.6.5 // indirect
github.com/julz/importas v0.2.0 // indirect
github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect
github.com/kisielk/errcheck v1.10.0 // indirect
github.com/kkHAIKE/contextcheck v1.1.6 // indirect
github.com/klauspost/compress v1.19.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/kulti/thelper v0.7.1 // indirect
github.com/kunwardeep/paralleltest v1.0.15 // indirect
github.com/lasiar/canonicalheader v1.1.2 // indirect
github.com/ldez/exptostd v0.4.5 // indirect
github.com/ldez/gomoddirectives v0.8.0 // indirect
github.com/ldez/grignotin v0.10.1 // indirect
github.com/ldez/structtags v0.6.1 // indirect
github.com/ldez/tagliatelle v0.7.2 // indirect
github.com/ldez/usetesting v0.5.0 // indirect
github.com/leonklingele/grouper v1.1.2 // indirect
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
github.com/macabu/inamedparam v0.2.0 // indirect
github.com/magiconair/properties v1.8.6 // indirect
github.com/manuelarte/embeddedstructfieldcheck v0.4.0 // indirect
github.com/manuelarte/funcorder v0.6.0 // indirect
github.com/maratori/testableexamples v1.0.1 // indirect
github.com/maratori/testpackage v1.1.2 // indirect
github.com/matoous/godox v1.1.0 // indirect
github.com/mattn/go-colorable v0.1.15 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.23 // indirect
github.com/mgechev/revive v1.15.0 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/moby/api v1.54.2 // indirect
github.com/moby/moby/client v0.4.1 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/moby/moby/api v1.55.0 // indirect
github.com/moby/moby/client v0.5.0 // indirect
github.com/moricho/tparallel v0.3.2 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nakabonne/nestif v0.3.1 // indirect
github.com/nishanths/exhaustive v0.12.0 // indirect
github.com/nishanths/predeclared v0.2.2 // indirect
github.com/nunnatsa/ginkgolinter v0.23.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/petermattis/goid v0.0.0-20260330135022-df67b199bc81 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
github.com/petermattis/goid v0.0.0-20260701081913-4f67fd55d3b4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/prometheus/common v0.69.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/quasilyte/go-ruleguard v0.4.5 // indirect
github.com/quasilyte/go-ruleguard/dsl v0.3.23 // indirect
github.com/quasilyte/gogrep v0.5.0 // indirect
github.com/quasilyte/regex/syntax v0.0.0-20210819130434-b3f0c404a727 // indirect
github.com/quasilyte/stdinfo v0.0.0-20220114132959-f7386bf02567 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/raeperd/recvcheck v0.2.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/cors v1.11.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/ryancurrah/gomodguard v1.4.1 // indirect
github.com/ryancurrah/gomodguard/v2 v2.1.3 // indirect
github.com/ryanrolds/sqlclosecheck v0.6.0 // indirect
github.com/sanposhiho/wastedassign/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sashamelentyev/interfacebloat v1.1.0 // indirect
github.com/sashamelentyev/usestdlibvars v1.29.0 // indirect
github.com/securego/gosec/v2 v2.26.1 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sivchari/containedctx v1.0.3 // indirect
github.com/sonatard/noctx v0.5.1 // indirect
github.com/sourcegraph/go-diff v0.8.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.5.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/stoewer/go-strcase v1.3.1 // indirect
github.com/tetratelabs/wazero v1.11.0 // indirect
github.com/spf13/viper v1.12.0 // indirect
github.com/ssgreg/nlreturn/v2 v2.2.1 // indirect
github.com/stbenjam/no-sprintf-host-port v0.3.1 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/subosito/gotenv v1.4.1 // indirect
github.com/tetafro/godot v1.5.6 // indirect
github.com/tetratelabs/wazero v1.12.0 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/vbatts/tar-split v0.12.3 // indirect
go.lsp.dev/jsonrpc2 v0.10.0 // indirect
go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2 // indirect
go.lsp.dev/protocol v0.12.0 // indirect
go.lsp.dev/uri v0.3.0 // indirect
github.com/timakin/bodyclose v0.0.0-20260129054331-73d1f95b84b4 // indirect
github.com/timonwong/loggercheck v0.11.0 // indirect
github.com/tomarrell/wrapcheck/v2 v2.12.0 // indirect
github.com/tommy-muehle/go-mnd/v2 v2.5.1 // indirect
github.com/ultraware/funlen v0.2.0 // indirect
github.com/ultraware/whitespace v0.2.0 // indirect
github.com/uudashr/gocognit v1.2.1 // indirect
github.com/uudashr/iface v1.4.2 // indirect
github.com/xen0n/gosmopolitan v1.3.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yagipy/maintidx v1.0.0 // indirect
github.com/yeya24/promlinter v0.3.0 // indirect
github.com/ykadowak/zerologlint v0.1.5 // indirect
gitlab.com/bosi/decorder v0.4.2 // indirect
go-simpler.org/musttag v0.14.0 // indirect
go-simpler.org/sloglint v0.12.0 // indirect
go.augendre.info/arangolint v0.4.0 // indirect
go.augendre.info/fatcontext v0.9.0 // indirect
go.lsp.dev/jsonrpc2 v1.0.1 // indirect
go.lsp.dev/protocol v1.0.1 // indirect
go.lsp.dev/uri v1.0.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/mock v0.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20260508232706-74f9aab9d74a // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.39.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260523011958-0a33c5d7ca68 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260523011958-0a33c5d7ca68 // indirect
google.golang.org/grpc v1.79.3 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260706201446-f0a921348800 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260706201446-f0a921348800 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
honnef.co/go/tools v0.7.0 // indirect
mvdan.cc/gofumpt v0.10.0 // indirect
mvdan.cc/unparam v0.0.0-20251027182757-5beb8c8f8f15 // indirect
mvdan.cc/xurls/v2 v2.6.0 // indirect
pluginrpc.com/pluginrpc v0.5.0 // indirect
)

File diff suppressed because it is too large Load Diff

View File

@ -134,7 +134,7 @@ func IsAllowedKill(cfg *config.Config, user *authpublic.AuthenticatedUser, actio
return aclCheck(Kill, cfg.DefaultPermissions.Kill, cfg, "isAllowedKill", user, action)
}
func isACLRelevantToAction(cfg *config.Config, actionAcls []string, acl *config.AccessControlList, user *authpublic.AuthenticatedUser) bool {
func isACLRelevantToAction(actionAcls []string, acl *config.AccessControlList, user *authpublic.AuthenticatedUser) bool {
if !slices.Contains(user.Acls, acl.Name) {
// If the user does not have this ACL, then it is not relevant
@ -156,7 +156,7 @@ func getRelevantAcls(cfg *config.Config, actionAcls []string, user *authpublic.A
var ret []*config.AccessControlList
for _, acl := range cfg.AccessControlLists {
if isACLRelevantToAction(cfg, actionAcls, acl, user) {
if isACLRelevantToAction(actionAcls, acl, user) {
ret = append(ret, acl)
}
}

View File

@ -46,7 +46,7 @@ func createKeys(t *testing.T) (*rsa.PrivateKey, string) {
if err != nil {
t.Fatalf("failed to create temp file: %v", err)
}
defer tmpFile.Close()
defer func() { _ = tmpFile.Close() }()
t.Logf("Created File: %s", tmpFile.Name())
@ -65,10 +65,6 @@ func newMux() *http.ServeMux {
return mux
}
func createJWTTokenWithExpiration(t *testing.T, privateKey *rsa.PrivateKey, expire int64) string {
return createJWTTokenWithExpirationAndAudience(t, privateKey, expire, "")
}
func createJWTTokenWithExpirationAndAudience(t *testing.T, privateKey *rsa.PrivateKey, expire int64, audience string) string {
token := jwt.New(jwt.SigningMethodRS256)
claims := token.Claims.(jwt.MapClaims)
@ -108,7 +104,7 @@ func setupJWTTestHandler(t *testing.T, cfg *config.Config) http.Handler {
}
func verifyJWTResponse(t *testing.T, res *http.Response, expectCode int) {
defer res.Body.Close()
defer func() { _ = res.Body.Close() }()
assert.Equal(t, expectCode, res.StatusCode)
body, _ := io.ReadAll(res.Body)
t.Logf("Response body: %s", string(body))
@ -120,7 +116,7 @@ func testJwkValidation(t *testing.T, expire int64, expectCode int) {
func testJwkValidationWithAudience(t *testing.T, expire int64, expectCode int, configAudience, tokenAudience string) {
privateKey, publicKeyPath := createKeys(t)
defer os.Remove(publicKeyPath)
defer func() { _ = os.Remove(publicKeyPath) }()
cfg := config.DefaultConfig()
cfg.AuthJwtPubKeyPath = publicKeyPath
@ -186,7 +182,7 @@ func makeJWTRequest(t *testing.T, srv *httptest.Server, tokenStr string) *http.R
func TestJWTHeader(t *testing.T) {
privateKey, publicKeyPath := createKeys(t)
defer os.Remove(publicKeyPath)
defer func() { _ = os.Remove(publicKeyPath) }()
cfg := config.DefaultConfig()
cfg.AuthJwtPubKeyPath = publicKeyPath
@ -217,7 +213,7 @@ func TestJWTHeader(t *testing.T) {
defer srv.Close()
res := makeJWTRequest(t, srv, tokenStr)
defer res.Body.Close()
defer func() { _ = res.Body.Close() }()
assert.Equal(t, 200, res.StatusCode)
body, _ := io.ReadAll(res.Body)

View File

@ -354,7 +354,7 @@ func getUserInfo(cfg *config.Config, client *http.Client, provider *config.OAuth
return ret
}
defer res.Body.Close()
defer func() { _ = res.Body.Close() }()
contents, err := io.ReadAll(res.Body)
@ -369,7 +369,7 @@ func getUserInfo(cfg *config.Config, client *http.Client, provider *config.OAuth
log.Debugf("OAuth2 User Data: %v+", string(contents))
}
err = json.Unmarshal([]byte(contents), &userData)
err = json.Unmarshal(contents, &userData)
if err != nil {
log.Errorf("Failed to unmarshal user data: %v", err)

View File

@ -31,12 +31,12 @@ actions:
// Create temporary file
tmpFile, err := os.CreateTemp("", "test_config_*.yaml")
assert.NoError(t, err, "Should create temporary file")
defer os.Remove(tmpFile.Name())
defer func() { _ = os.Remove(tmpFile.Name()) }()
// Write test config to file
_, err = tmpFile.WriteString(testConfig)
assert.NoError(t, err, "Should write test config to file")
tmpFile.Close()
assert.NoError(t, tmpFile.Close())
// Load config using koanf
k := koanf.New(".")
@ -82,11 +82,11 @@ actions:
tmpFile, err := os.CreateTemp("", "test_config_empty_*.yaml")
assert.NoError(t, err, "Should create temporary file")
defer os.Remove(tmpFile.Name())
defer func() { _ = os.Remove(tmpFile.Name()) }()
_, err = tmpFile.WriteString(testConfig)
assert.NoError(t, err, "Should write test config to file")
tmpFile.Close()
assert.NoError(t, tmpFile.Close())
k := koanf.New(".")
err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
@ -117,11 +117,11 @@ actions:
tmpFile, err := os.CreateTemp("", "test_config_disabled_*.yaml")
assert.NoError(t, err, "Should create temporary file")
defer os.Remove(tmpFile.Name())
defer func() { _ = os.Remove(tmpFile.Name()) }()
_, err = tmpFile.WriteString(testConfig)
assert.NoError(t, err, "Should write test config to file")
tmpFile.Close()
assert.NoError(t, tmpFile.Close())
k := koanf.New(".")
err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
@ -148,11 +148,11 @@ actions:
tmpFile, err := os.CreateTemp("", "test_config_no_auth_*.yaml")
assert.NoError(t, err, "Should create temporary file")
defer os.Remove(tmpFile.Name())
defer func() { _ = os.Remove(tmpFile.Name()) }()
_, err = tmpFile.WriteString(testConfig)
assert.NoError(t, err, "Should write test config to file")
tmpFile.Close()
assert.NoError(t, tmpFile.Close())
k := koanf.New(".")
err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())

View File

@ -982,7 +982,7 @@ func validatedSystemArgs(req *ExecutionRequest) (map[string]string, error) {
}
func hasBindingAndAction(req *ExecutionRequest) bool {
return !(req.Binding == nil || req.Binding.Action == nil)
return req.Binding != nil && req.Binding.Action != nil
}
func hasExec(req *ExecutionRequest) bool {

View File

@ -186,16 +186,6 @@ func (e *Executor) groupsHaveCapacityForQueued(req *ExecutionRequest) bool {
return true
}
func firstFullGroupName(e *Executor, req *ExecutionRequest) string {
for _, limit := range actionGroupLimits(req) {
if e.countActiveInGroup(limit.name) >= (limit.maxConcurrent + 1) {
return limit.name
}
}
return ""
}
func firstFullGroupNameLocked(e *Executor, req *ExecutionRequest) string {
for _, limit := range actionGroupLimits(req) {
if e.countActiveInGroupLocked(limit.name) >= (limit.maxConcurrent + 1) {

View File

@ -55,7 +55,7 @@ func (tc *timeoutContext) setProcess(process *os.Process) {
tc.processMu.Unlock()
// If deadline already expired before process was set, kill now
if tc.Context.Err() == context.DeadlineExceeded && process != nil {
if tc.Err() == context.DeadlineExceeded && process != nil {
logEntry := &InternalLogEntry{Process: process}
if err := tc.executor.Kill(logEntry); err != nil {
log.WithFields(log.Fields{

View File

@ -74,7 +74,7 @@ func watchPath(ctx *watchContext) {
return
}
defer watcher.Close()
defer func() { _ = watcher.Close() }()
done := make(chan bool)

View File

@ -15,8 +15,6 @@ func WriteFile(filename string, out []byte) {
if _, err := os.Stat(filename); os.IsNotExist(err) {
handle, err := os.Create(filename)
handle.Close()
if err != nil {
log.WithFields(log.Fields{
"error": err,
@ -25,6 +23,7 @@ func WriteFile(filename string, out []byte) {
return
}
_ = handle.Close()
}
err := os.WriteFile(filename, out, 0600)

View File

@ -96,7 +96,7 @@ func getOsReleasePrettyName() string {
}
}
handle.Close()
_ = handle.Close()
return "notfound"
}

View File

@ -1,7 +1,6 @@
package servicehost
import (
"os"
"path/filepath"
)
@ -20,25 +19,3 @@ func resolveLogDirectory(dir string, baseDir string) string {
return filepath.Join(baseDir, dir)
}
func executableDirectory() (string, error) {
ex, err := os.Executable()
if err != nil {
return "", err
}
return filepath.Dir(ex), nil
}
func configuredServiceLogDirectory(dir string) (string, error) {
if dir == "" {
return "", nil
}
exeDir, err := executableDirectory()
if err != nil {
return "", err
}
return resolveLogDirectory(dir, exeDir), nil
}

View File

@ -0,0 +1,30 @@
//go:build windows
package servicehost
import (
"os"
"path/filepath"
)
func executableDirectory() (string, error) {
ex, err := os.Executable()
if err != nil {
return "", err
}
return filepath.Dir(ex), nil
}
func configuredServiceLogDirectory(dir string) (string, error) {
if dir == "" {
return "", nil
}
exeDir, err := executableDirectory()
if err != nil {
return "", err
}
return resolveLogDirectory(dir, exeDir), nil
}

View File

@ -202,7 +202,7 @@ func appendFile(path, content string) error {
if err != nil {
return err
}
defer file.Close()
defer func() { _ = file.Close() }()
_, err = file.WriteString(content)
return err

View File

@ -5,7 +5,6 @@ package tools
import (
_ "github.com/bufbuild/buf/cmd/buf"
_ "github.com/fzipp/gocyclo/cmd/gocyclo"
_ "github.com/go-critic/go-critic/cmd/gocritic"
_ "github.com/golangci/golangci-lint/v2/cmd/golangci-lint"
_ "google.golang.org/protobuf/cmd/protoc-gen-go"
)