This commit is contained in:
James Read 2026-07-21 19:43:12 +00:00 committed by GitHub
commit c56b4f3855
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
77 changed files with 4615 additions and 2866 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

@ -156,8 +156,10 @@ jobs:
if: steps.release.outputs.new_release_published == 'true'
uses: actions/upload-artifact@v7
with:
name: unsigned-windows-zip
# Upload as-is so SignPath receives OliveTin-windows-amd64.zip, not a wrapper zip.
# With archive: false, the artifact name is the filename (`name` is ignored).
path: dist/OliveTin-windows-amd64.zip
archive: false
if-no-files-found: error
- name: Upload unsigned Windows MSI for SignPath
@ -165,8 +167,8 @@ jobs:
if: steps.release.outputs.new_release_published == 'true'
uses: actions/upload-artifact@v7
with:
name: unsigned-windows-msi
path: dist/OliveTin-windows-amd64.msi
archive: false
if-no-files-found: error
- name: Archive binaries
@ -205,7 +207,7 @@ jobs:
fi
done
if [[ "${missing}" -ne 0 ]]; then
echo "Windows signing is required before a draft release can be published. Configure SignPath secrets/vars (see docs/modules/dev/pages/signing.adoc)." >&2
echo "SignPath secrets/vars are required to upload signed Windows assets. Configure them (see docs/modules/dev/pages/signing.adoc)." >&2
exit 1
fi
@ -219,6 +221,8 @@ jobs:
artifact-configuration-slug: windows-zip
github-artifact-id: ${{ needs.build.outputs.windows_zip_artifact_id }}
wait-for-completion: true
# Preserve the signed .zip/.msi files; default decompress would unpack the zip.
skip-decompress: true
output-artifact-directory: signed-windows-zip
- name: Sign Windows MSI
@ -231,9 +235,10 @@ jobs:
artifact-configuration-slug: windows-msi
github-artifact-id: ${{ needs.build.outputs.windows_msi_artifact_id }}
wait-for-completion: true
skip-decompress: true
output-artifact-directory: signed-windows-msi
- name: Publish signed Windows assets and undraft release
- name: Upload signed Windows assets
env:
GH_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
GITHUB_TOKEN: ${{ secrets.CONTAINER_TOKEN }}

View File

@ -6,6 +6,7 @@ before:
- make windows-resources VERSION={{ .Version }}
builds:
# id defaults to project_name (OliveTin) — required for notarize.macos defaults.
- skip: '{{ isEnvSet "GORELEASER_SKIP_BUILD" }}'
env:
- CGO_ENABLED=0
@ -14,7 +15,6 @@ builds:
dir: service
goos:
- linux
- windows
- darwin
- freebsd
@ -33,19 +33,31 @@ builds:
- goos: darwin
goarch: arm # Mac does not work on [32bit] arm
- goos: windows
goarch: arm
- goos: windows # Does anyone use Windows on arm64?
goarch: arm64
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X main.date={{ .CommitDate }}
# Built and archived for SignPath, but excluded from the GitHub release upload.
- id: windows
skip: '{{ isEnvSet "GORELEASER_SKIP_BUILD" }}'
env:
- CGO_ENABLED=0
binary: OliveTin
main: .
dir: service
goos:
- windows
goarch:
- amd64
ldflags:
- -s -w -X main.version={{.Version}} -X main.commit={{.ShortCommit}} -X main.date={{ .CommitDate }}
checksum:
name_template: 'checksums.txt'
extra_files:
- glob: ./dist/OliveTin-windows-amd64.msi
# Exclude the windows archive; signed zip/MSI checksums are added after SignPath.
ids:
- default
- openrc
snapshot:
version_template: "{{ .Branch }}-{{ .ShortCommit }}"
changelog:
@ -73,7 +85,26 @@ changelog:
- '^Merge branch'
archives:
- formats: tar.gz
- id: default
ids:
- OliveTin
formats: tar.gz
files:
- config.yaml
- LICENSE
- README.md
- src: Dockerfile.singlearch
dst: Dockerfile
- examples/backupScript.sh
- webui
- var
name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}{{ .Arm }}"
wrap_in_directory: true
- id: windows
ids:
- windows
formats: zip
files:
- config.yaml
- LICENSE
@ -85,9 +116,6 @@ archives:
- var
name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}{{ .Arm }}"
wrap_in_directory: true
format_overrides:
- goos: windows
formats: zip
# dockers_v2: single multi-platform build with buildx; keeps default provenance + SBOM.
# Replaces legacy dockers + docker_manifests (avoids "is a manifest list" when attestations are on).
@ -187,8 +215,7 @@ nfpms:
notarize:
macos:
- enabled: '{{ isEnvSet "MACOS_SIGN_P12" }}'
ids:
- default
# Defaults to project_name when omitted; must match builds[].id (also project_name).
sign:
certificate: "{{.Env.MACOS_SIGN_P12}}"
password: "{{.Env.MACOS_SIGN_PASSWORD}}"
@ -200,10 +227,11 @@ notarize:
timeout: 30m
release:
# Stay draft until the sign-windows job replaces unsigned Windows assets and undrafts.
draft: true
extra_files:
- glob: ./dist/OliveTin-windows-amd64.msi
# Publish immediately without Windows assets; sign-windows uploads signed zip/MSI later.
draft: false
ids:
- default
- openrc
footer: |
## Container images (from GitHub)

View File

@ -38,32 +38,33 @@ repos:
entry: make service-codestyle
language: system
pass_filenames: false
always_run: true
files: ^(service/|proto/|lang/|Makefile)
- id: frontend-codestyle
name: frontend-codestyle
entry: make frontend-codestyle
language: system
pass_filenames: false
always_run: true
files: ^(frontend/|Makefile)
- id: service-unittests
name: service-unittests
entry: make service-unittests
language: system
pass_filenames: false
always_run: true
files: ^(service/|proto/|lang/|Makefile)
- id: service-build
name: service-build
entry: make service
language: system
pass_filenames: false
always_run: true
files: ^(service/|proto/|lang/|Makefile)
- 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

@ -74,7 +74,7 @@ defaultPermissions:
logs: true
----
In the example above, all users will start off with the permissions to only see action logs - but will not be able to view or execute actions.
In the example above, all users will start off with the permissions to only see action logs - but will not be able to view or execute actions.
It is then possible to add an "admins" ACL on top of every action. In the example below, we define one extra ACL called "admins", which matches any users with the usergroup also called "admins". This ACL will then be applied to all actions, and will allow users in the "admins" usergroup to view and execute the action.
@ -116,6 +116,48 @@ accessControlLists:
addToEveryAction: true
```
== ACLs and Dashboards
Root dashboards can also list `acls`. This controls whether the **whole dashboard page** is visible (including `display` widgets and entity fieldsets), not just action buttons.
* If a dashboard has **no** `acls` (or an empty list), it is unrestricted — anyone can see it in the side menu (subject to the usual “empty dashboard” hiding).
* If a dashboard lists one or more `acls`, access uses the same allow-list rules as actions: a matching ACL that grants `view`, otherwise `defaultPermissions.view`.
* `addToEveryAction` does **not** apply to dashboards. List the ACL on the dashboard explicitly when you want to restrict it.
* Nested fieldsets and directories do not have their own `acls`; the root dashboard decision covers the whole page.
Action `acls` still control individual buttons. Use dashboard `acls` when you need to hide a page that contains status or other non-action content from some users.
[source,yaml]
.`config.yaml`
----
defaultPermissions:
view: false
exec: false
accessControlLists:
- name: admins
matchUsergroups:
- admins
permissions:
view: true
exec: true
dashboards:
- title: Public tools
contents:
- title: Welcome
type: display
- title: Services
acls:
- admins
contents:
- title: 'Status: running'
type: display
----
In the example above, guests can open **Public tools**, but **Services** is hidden from the side menu and cannot be loaded by deep link.
== ACL Matching - usernames and usergroups.
You can match users based on their usergroup which is the most common, but it is also possible to match based on the user's username.
@ -149,4 +191,3 @@ Now that you understand ACLs, here's how to implement them:
* xref:security/local.adoc[Set up local users] - Create users for ACL matching
* xref:security/oauth2.adoc[Configure OAuth2] - Set up OAuth2 for user groups
* xref:security/design_choices.adoc[Security design recommendations] - Learn best practices for ACL design

View File

@ -3,7 +3,7 @@
OliveTin signs release binaries on two platforms:
* **macOS** — Developer ID + notarization via [quill](https://github.com/anchore/quill) inside GoReleaser (optional only when `MACOS_SIGN_P12` is unset).
* **Windows** — Authenticode via [SignPath Foundation](https://signpath.org/) in a separate GitHub Actions job (required before a draft release is published).
* **Windows** — Authenticode via [SignPath Foundation](https://signpath.org/) in a separate GitHub Actions job (signed zip/MSI are uploaded after the release is published).
## macOS release signing
@ -117,14 +117,14 @@ A signed and notarized binary should report `accepted` with `source=Notarized De
Windows Authenticode signing uses [SignPath Foundation](https://signpath.org/) (free for qualifying open-source projects). It does **not** use GoReleaser Pro.
GoReleaser creates a **draft** GitHub release with unsigned Windows assets. A separate `sign-windows` job submits those assets to SignPath, replaces them on the draft (including updated `checksums.txt`), then publishes the release.
GoReleaser publishes a GitHub release **without** Windows zip/MSI assets. A separate `sign-windows` job submits the unsigned Windows files (as workflow artifacts) to SignPath, then uploads the signed zip/MSI and updates `checksums.txt` on the already-published release.
Signed artifacts:
* `OliveTin.exe` inside `OliveTin-windows-amd64.zip`
* nested `OliveTin.exe` and the `OliveTin-windows-amd64.msi` installer (deep signing)
Signing is **required** to publish. If SignPath secrets/vars are missing, `sign-windows` fails and the draft stays unpublished.
If SignPath secrets/vars are missing or signing fails, the release still publishes; Windows assets are simply missing until a successful `sign-windows` run. Install URLs for the Windows zip may 404 until signing finishes.
### Prerequisites
@ -168,9 +168,9 @@ In **Settings → Secrets and variables → Actions**:
On a new semantic-release from `main`:
1. GoReleaser publishes container images and creates a **draft** GitHub release (including unsigned Windows zip/MSI).
2. The build job uploads those Windows files as GitHub Actions artifacts.
3. The `sign-windows` job submits each artifact to SignPath, waits for completion, then runs `var/windows/signpath-publish-signed.sh` to clobber-upload signed files, refresh `checksums.txt`, and undraft the release.
1. GoReleaser publishes container images and a GitHub release **without** Windows zip/MSI assets (those are built locally for SignPath only).
2. The build job uploads the unsigned Windows files as GitHub Actions artifacts.
3. The `sign-windows` job submits each artifact to SignPath, waits for completion, then runs `var/windows/signpath-publish-signed.sh` to upload the signed files and refresh `checksums.txt`.
All jobs in this chain use GitHub-hosted runners (required by SignPath for OSS projects).
@ -188,7 +188,7 @@ signtool verify /pa OliveTin-windows-amd64.msi
### Configuration reference
- Draft release: `release.draft: true` in link:https://github.com/OliveTin/OliveTin/blob/main/.goreleaser.yml[`.goreleaser.yml`]
- Release publish (no Windows assets initially): `release.draft: false` and `release.ids` in link:https://github.com/OliveTin/OliveTin/blob/main/.goreleaser.yml[`.goreleaser.yml`]
- CI job: `sign-windows` in link:https://github.com/OliveTin/OliveTin/blob/main/.github/workflows/build-and-release.yml[`.github/workflows/build-and-release.yml`]
- Publish helper: link:https://github.com/OliveTin/OliveTin/blob/main/var/windows/signpath-publish-signed.sh[`var/windows/signpath-publish-signed.sh`]
- SignPath artifact configs (reference only): link:https://github.com/OliveTin/OliveTin/blob/main/docs/modules/dev/signpath/windows-zip.xml[`signpath/windows-zip.xml`], link:https://github.com/OliveTin/OliveTin/blob/main/docs/modules/dev/signpath/windows-msi.xml[`signpath/windows-msi.xml`]

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:
@ -18,4 +18,4 @@ build:
dist: deps clean build
.PHONY: codestyle
.PHONY: codestyle

View File

@ -11,38 +11,39 @@
"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": {
"node": ">=22.0.0"
"node": "^20.19.0 || >=22.12.0"
}
},
"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,22 +25,22 @@
"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"
},
"engines": {
"node": ">=22.0.0"
"node": "^20.19.0 || >=22.12.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,117 @@ 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
if (bindingId.value) {
rateLimits[bindingId.value] = rateLimitExpires.value
}
updateRateLimitStatus()
} else if (json.datetimeRateLimitExpires === '') {
// Explicitly clear if empty string
rateLimitExpires.value = 0
updateRateLimitStatus()
// Explicitly clear if empty string
rateLimitExpires.value = 0
if (bindingId.value) {
rateLimits[bindingId.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 +365,138 @@ async function pollExecutionUntilDone (trackingId) {
}
}
async function startAction(actionArgs) {
let stopButtonResultWatch = null
function stopWatchingButtonResult () {
if (stopButtonResultWatch) {
stopButtonResultWatch()
stopButtonResultWatch = null
}
}
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) => {
stopWatchingButtonResult()
stopButtonResultWatch = watch(
() => 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)
stopWatchingButtonResult()
console.error('Failed to start action:', err)
}
}
function onLogEntryChanged(logEntry) {
function onLogEntryChanged (logEntry) {
if (!logEntry) {
return
}
if (logEntry.executionFinished) {
onExecutionFinished(logEntry)
stopWatchingButtonResult()
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 +506,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,125 @@
<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 && !loadError"
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>
<section
v-else-if="loadError"
style="text-align: center; padding: 2em;"
class="bad"
>
<h2 style="color: var(--error);">
Failed to Load Dashboard
</h2>
<p>{{ loadError }}</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,189 +130,190 @@ 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()
const dashboard = ref(null)
const loadingTime = ref(0)
const initError = ref(null)
const loadError = ref(null)
let loadingTimer = null
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 errors since we successfully loaded
initError.value = null
loadError.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
}
console.error('Failed to load dashboard', e)
dashboard.value = null
loadError.value = e.message || 'Failed to load dashboard'
const pageTitle = window.initResponse?.pageTitle || 'OliveTin'
document.title = 'Error - ' + pageTitle
// Stop the loading timer on error
if (loadingTimer) {
clearInterval(loadingTimer)
loadingTimer = null
}
}
}
function waitForInitAndLoadDashboard () {
document.body.removeAttribute('loaded-dashboard')
loadError.value = null
dashboard.value = null
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 +347,6 @@ fieldset {
place-items: stretch;
}
@keyframes spin {
from {
transform: rotate(0deg);

View File

@ -1,6 +1,6 @@
<template>
<div
:id="`execution-${executionTrackingId}`"
<div
:id="`execution-${trackingId}`"
class="execution-button"
>
<button
@ -13,25 +13,27 @@
</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() {
emits: ['show'],
data () {
return {
trackingId: '',
ellapsed: 0,
isWaiting: true
}
},
computed: {
buttonText() {
buttonText () {
if (this.isWaiting) {
return 'Executing...'
} else {
@ -39,33 +41,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() {
async show () {
this.$emit('show')
if (window.executionDialog) {
window.executionDialog.reset()
await 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) {
@ -77,17 +80,20 @@ export default {
this.renderExecutionResult('action-success', 'Success!')
}
},
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) {
this.$el.classList.add(resultCssClass)
const button = this.$el.querySelector('button')
if (button) {
button.classList.add(resultCssClass)
}
}
}
}
@ -138,4 +144,4 @@ export default {
border-color: #c3e6cb;
color: #155724;
}
</style>
</style>

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>
</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,35 @@ 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
event.target.setCustomValidity('')
}
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
event.target.setCustomValidity('')
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 +340,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 +378,7 @@ async function validateArgument(arg, value) {
try {
const validateArgumentTypeArgs = {
value: value,
value,
type: arg.type,
bindingId: props.bindingId,
argumentName: arg.name
@ -348,7 +410,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 +425,7 @@ function updateUrlWithArg(name, value) {
}
}
function shouldSendArgument(arg) {
function shouldSendArgument (arg) {
if (!arg.name) {
return false
}
@ -371,7 +433,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 +449,7 @@ function formatArgumentValueForApi(arg, rawValue) {
return rawValue ?? ''
}
function getSelectedArgumentEntries() {
function getSelectedArgumentEntries () {
const entries = []
for (const arg of actionArguments.value) {
@ -404,17 +466,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 +484,7 @@ function updateJustificationFromTemplate() {
justificationValue.value = applyArgumentTemplate(justificationTemplate.value, getArgumentMapForTemplate())
}
function getUniqueId() {
function getUniqueId () {
if (window.isSecureContext) {
return window.crypto.randomUUID()
} else {
@ -430,7 +492,7 @@ function getUniqueId() {
}
}
function getBrowserSuggestions(arg) {
function getBrowserSuggestions (arg) {
if (!arg.suggestionsBrowserKey) {
return []
}
@ -448,7 +510,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 +546,7 @@ function saveBrowserSuggestions() {
}
}
async function startAction(actionArgs) {
async function startAction (actionArgs) {
const startActionArgs = {
bindingId: props.bindingId,
arguments: actionArgs,
@ -506,7 +568,7 @@ async function startAction(actionArgs) {
}
}
async function handleSubmit(event) {
async function handleSubmit (event) {
event.preventDefault()
if (!formReady.value) {
@ -568,24 +630,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 +700,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,22 @@ async function fetchDiagnostics() {
loading.value = false
}
function formatKey(key) {
return key
.replace(/([A-Z])/g, ' $1')
.replace(/^./, str => str.toUpperCase())
.trim()
async function generateServerDiagnostics () {
loading.value = true
try {
const response = await window.client.serverDiagnostics()
console.log('response', response)
serverDiagnostics.value = `\`\`\`\n${response.alert}\n\`\`\`\n`
} catch (err) {
console.error('Failed to generate server diagnostics:', err)
serverDiagnostics.value = ''
} finally {
loading.value = false
}
}
async function generateServerDiagnostics() {
const response = await window.client.serverDiagnostics()
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 +143,7 @@ async function copyServerDiagnostics() {
}
}
async function generateBrowserInfo() {
async function generateBrowserInfo () {
loading.value = true
try {
let userAgentData = 'N/A'
@ -159,7 +200,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 +209,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 +230,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 +249,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,83 @@
<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="providerIcon(provider)"
class="provider-icon"
>
<iconify-icon
v-if="providerIcon(provider).kind === 'iconify'"
:icon="providerIcon(provider).id"
/>
<span v-else>{{ providerIcon(provider).text }}</span>
</span>
<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 +101,39 @@ const hasOAuth = ref(false)
const hasLocalLogin = ref(false)
const oauthProviders = ref([])
function loadLoginOptions() {
const trustedProviderIconifyIds = {
github: 'simple-icons:github',
google: 'simple-icons:google'
}
function providerIcon (provider) {
const raw = (provider?.icon || '').trim()
if (!raw) {
return null
}
const iconifyTagMatch = raw.match(/<iconify-icon\b[^>]*\bicon=["']([^"']+)["'][^>]*>/i)
if (iconifyTagMatch) {
return { kind: 'iconify', id: iconifyTagMatch[1] }
}
if (/^[a-z0-9-]+:[a-z0-9-]+$/i.test(raw)) {
return { kind: 'iconify', id: raw }
}
const trustedId = trustedProviderIconifyIds[provider.key]
if (trustedId && (raw.includes('<') || raw === provider.key)) {
return { kind: 'iconify', id: trustedId }
}
if (!raw.includes('<')) {
return { kind: 'text', text: raw }
}
return trustedId ? { kind: 'iconify', id: trustedId } : null
}
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 +147,7 @@ function loadLoginOptions() {
}
}
async function handleLocalLogin() {
async function handleLocalLogin () {
loading.value = true
loginError.value = ''
@ -83,7 +165,7 @@ async function handleLocalLogin() {
window.initError = false
window.initErrorMessage = ''
window.initCompleted = true
// Update the header with new user info
if (window.updateHeaderFromInit) {
window.updateHeaderFromInit()
@ -91,7 +173,7 @@ async function handleLocalLogin() {
} catch (initErr) {
console.error('Failed to reinitialize after login:', initErr)
}
// Redirect to home page on successful login
router.push('/')
} else {
@ -105,21 +187,21 @@ async function handleLocalLogin() {
}
}
function loginWithOAuth(provider) {
function loginWithOAuth (provider) {
if (!provider.key) {
console.error('OAuth provider missing key:', provider)
return
}
const providerKey = encodeURIComponent(provider.key)
window.location.href = `/oauth/login?provider=${providerKey}`
}
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>
@ -52,7 +66,7 @@ const calendarEvents = computed(() => {
.map(log => {
const startDate = new Date(log.datetimeStarted)
let endDate = log.datetimeFinished ? new Date(log.datetimeFinished) : null
// Validate end date
if (endDate && isNaN(endDate.getTime())) {
endDate = null
@ -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,44 +86,43 @@ const calendarEvents = computed(() => {
})
})
async function fetchLogs() {
async function fetchLogs () {
loading.value = true
error.value = null
try {
// Currently fetches only the default page (startOffset: 0)
// Multi-page fetching: loop through pages until no more logs or limit reached
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)
const pageLogs = response.logs || []
// If no logs returned, we've reached the end
if (pageLogs.length === 0) {
break
}
// Append logs from this page
allLogs.push(...pageLogs)
// Update offset for next page
const currentPageSize = Number(response.pageSize) || pageLogs.length
startOffset += BigInt(currentPageSize)
// If we got fewer logs than the page size, we've reached the end
if (pageLogs.length < currentPageSize) {
break
}
}
logs.value = allLogs
} catch (err) {
console.error('Failed to fetch logs:', err)
@ -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

@ -4,12 +4,18 @@
<div class="not-found-content">
<h1>404</h1>
<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('/')
}
}
@ -57,4 +63,4 @@ export default {
color: #666;
margin-bottom: 2rem;
}
</style>
</style>

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,12 +98,12 @@ async function fetchWhoAmI() {
}
}
async function handleLogout() {
async function handleLogout () {
loggingOut.value = true
try {
await window.client.logout({})
// Re-initialize to get updated user context (should be guest)
try {
const initResponse = await window.client.init({})
@ -80,7 +111,7 @@ async function handleLogout() {
window.initError = false
window.initErrorMessage = ''
window.initCompleted = true
// Update the header with new user info
if (window.updateHeaderFromInit) {
window.updateHeaderFromInit()
@ -88,7 +119,7 @@ async function handleLogout() {
} catch (initErr) {
console.error('Failed to reinitialize after logout:', initErr)
}
// Redirect based on init response: if login is required, go to login page
if (window.initResponse && window.initResponse.loginRequired) {
router.push('/login')
@ -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

@ -0,0 +1,33 @@
#
# Integration Test Config: dashboardAcls
#
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
logLevel: "DEBUG"
checkForUpdates: false
defaultPermissions:
view: false
exec: false
accessControlLists:
- name: admins
matchUsergroups:
- admins
permissions:
view: true
exec: true
dashboards:
- title: Public tools
contents:
- title: Welcome guest
type: display
- title: Services
acls:
- admins
contents:
- title: 'Status: running'
type: display

View File

@ -0,0 +1,38 @@
import { describe, it, before, after } from 'mocha'
import { expect } from 'chai'
import {
getRootAndWait,
openSidebar,
getNavigationLinks,
takeScreenshotOnFailure,
} from '../../lib/elements.js'
describe('config: dashboardAcls', function () {
before(async function () {
await runner.start('dashboardAcls')
})
after(async () => {
await runner.stop()
})
afterEach(function () {
takeScreenshotOnFailure(this.currentTest, webdriver)
})
it('hides ACL-restricted dashboards from guests in the side menu', async function () {
await getRootAndWait()
await openSidebar()
const navigationLinks = await getNavigationLinks()
expect(navigationLinks).to.not.be.empty
const linkTexts = []
for (const link of navigationLinks) {
linkTexts.push(await link.getText())
}
expect(linkTexts).to.include('Public tools')
expect(linkTexts).to.not.include('Services')
})
})

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)",

23
service/.golangci.yml Normal file
View File

@ -0,0 +1,23 @@
version: "2"
run:
timeout: 5m
tests: true
linters:
default: none
enable:
- errcheck
- gocritic
- gocyclo
- ineffassign
- misspell
- staticcheck
- unconvert
- unused
settings:
gocyclo:
min-complexity: 5
exclusions:
paths:
- 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@v2.12.2"
.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@v2.12.2"
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

@ -21,38 +21,36 @@ func (p PermissionBits) Has(permission PermissionBits) bool {
return p&permission != 0
}
func logAclNotMatched(cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, action *config.Action, acl *config.AccessControlList) {
func logAclNotMatched(cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, resourceTitle string, acl *config.AccessControlList) {
if cfg.LogDebugOptions.AclNotMatched {
log.WithFields(log.Fields{
"User": user.Username,
"Action": action.Title,
"ACL": acl.Name,
"User": user.Username,
"Resource": resourceTitle,
"ACL": acl.Name,
}).Debugf("%v - ACL Not Matched", aclFunction)
}
}
func logAclMatched(cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, action *config.Action, acl *config.AccessControlList) {
actionTitle := "N/A"
if action != nil {
actionTitle = action.Title
func logAclMatched(cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, resourceTitle string, acl *config.AccessControlList) {
if resourceTitle == "" {
resourceTitle = "N/A"
}
if cfg.LogDebugOptions.AclMatched {
log.WithFields(log.Fields{
"User": user.Username,
"Action": actionTitle,
"ACL": acl.Name,
"User": user.Username,
"Resource": resourceTitle,
"ACL": acl.Name,
}).Debugf("%v - Matched ACL", aclFunction)
}
}
func logAclNoneMatched(cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, action *config.Action, defaultPermission bool) {
func logAclNoneMatched(cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, resourceTitle string, defaultPermission bool) {
if cfg.LogDebugOptions.AclNoneMatched {
log.WithFields(log.Fields{
"User": user.Username,
"Action": action.Title,
"Default": defaultPermission,
"User": user.Username,
"Resource": resourceTitle,
"Default": defaultPermission,
}).Debugf("%v - No ACLs Matched, returning default permission", aclFunction)
}
}
@ -81,12 +79,12 @@ func permissionsConfigToBits(permissions config.PermissionsList) PermissionBits
return ret
}
func aclCheck(requiredPermission PermissionBits, defaultValue bool, cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, action *config.Action) bool {
relevantAcls := getRelevantAcls(cfg, action.Acls, user)
func aclCheck(requiredPermission PermissionBits, defaultValue bool, cfg *config.Config, aclFunction string, user *authpublic.AuthenticatedUser, resourceTitle string, resourceAcls []string, includeAddToEvery bool) bool {
relevantAcls := getRelevantAcls(cfg, resourceAcls, user, includeAddToEvery)
if cfg.LogDebugOptions.AclCheckStarted {
log.WithFields(log.Fields{
"actionTitle": action.Title,
"resourceTitle": resourceTitle,
"username": user.Username,
"usergroupLine": user.UsergroupLine,
"relevantAcls": len(relevantAcls),
@ -98,27 +96,27 @@ func aclCheck(requiredPermission PermissionBits, defaultValue bool, cfg *config.
permissionBits := permissionsConfigToBits(acl.Permissions)
if permissionBits.Has(requiredPermission) {
logAclMatched(cfg, aclFunction, user, action, acl)
logAclMatched(cfg, aclFunction, user, resourceTitle, acl)
return true
} else {
logAclNotMatched(cfg, aclFunction, user, action, acl)
}
logAclNotMatched(cfg, aclFunction, user, resourceTitle, acl)
}
logAclNoneMatched(cfg, aclFunction, user, action, cfg.DefaultPermissions.Logs)
logAclNoneMatched(cfg, aclFunction, user, resourceTitle, defaultValue)
return defaultValue
}
// IsAllowedLogs checks if a AuthenticatedUser is allowed to view an action's logs
func IsAllowedLogs(cfg *config.Config, user *authpublic.AuthenticatedUser, action *config.Action) bool {
return aclCheck(Logs, cfg.DefaultPermissions.Logs, cfg, "isAllowedLogs", user, action)
return aclCheck(Logs, cfg.DefaultPermissions.Logs, cfg, "isAllowedLogs", user, action.Title, action.Acls, true)
}
// IsAllowedExec checks if a AuthenticatedUser is allowed to execute an Action
func IsAllowedExec(cfg *config.Config, user *authpublic.AuthenticatedUser, action *config.Action) bool {
return aclCheck(Exec, cfg.DefaultPermissions.Exec, cfg, "isAllowedExec", user, action)
return aclCheck(Exec, cfg.DefaultPermissions.Exec, cfg, "isAllowedExec", user, action.Title, action.Acls, true)
}
// IsAllowedView checks if a User is allowed to view an Action
@ -127,36 +125,40 @@ func IsAllowedView(cfg *config.Config, user *authpublic.AuthenticatedUser, actio
return false
}
return aclCheck(View, cfg.DefaultPermissions.View, cfg, "isAllowedView", user, action)
return aclCheck(View, cfg.DefaultPermissions.View, cfg, "isAllowedView", user, action.Title, action.Acls, true)
}
func IsAllowedKill(cfg *config.Config, user *authpublic.AuthenticatedUser, action *config.Action) bool {
return aclCheck(Kill, cfg.DefaultPermissions.Kill, cfg, "isAllowedKill", user, action)
return aclCheck(Kill, cfg.DefaultPermissions.Kill, cfg, "isAllowedKill", user, action.Title, action.Acls, true)
}
func isACLRelevantToAction(cfg *config.Config, 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
// IsAllowedViewDashboard checks if a user may see a root dashboard.
// Dashboards with no acls are unrestricted. AddToEveryAction does not apply.
func IsAllowedViewDashboard(cfg *config.Config, user *authpublic.AuthenticatedUser, dashboard *config.DashboardComponent) bool {
if dashboard == nil || len(dashboard.Acls) == 0 {
return true
}
return aclCheck(View, cfg.DefaultPermissions.View, cfg, "isAllowedViewDashboard", user, dashboard.Title, dashboard.Acls, false)
}
func isACLRelevant(resourceAcls []string, acl *config.AccessControlList, user *authpublic.AuthenticatedUser, includeAddToEvery bool) bool {
if !slices.Contains(user.Acls, acl.Name) {
return false
}
if acl.AddToEveryAction {
if includeAddToEvery && acl.AddToEveryAction {
return true
}
if slices.Contains(actionAcls, acl.Name) {
return true
}
return false
return slices.Contains(resourceAcls, acl.Name)
}
func getRelevantAcls(cfg *config.Config, actionAcls []string, user *authpublic.AuthenticatedUser) []*config.AccessControlList {
func getRelevantAcls(cfg *config.Config, resourceAcls []string, user *authpublic.AuthenticatedUser, includeAddToEvery bool) []*config.AccessControlList {
var ret []*config.AccessControlList
for _, acl := range cfg.AccessControlLists {
if isACLRelevantToAction(cfg, actionAcls, acl, user) {
if isACLRelevant(resourceAcls, acl, user, includeAddToEvery) {
ret = append(ret, acl)
}
}

View File

@ -0,0 +1,86 @@
package acl
import (
"testing"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/stretchr/testify/assert"
)
func TestIsAllowedViewDashboardAbsentAclsUnrestricted(t *testing.T) {
cfg := config.DefaultConfig()
cfg.DefaultPermissions.View = false
dashboard := &config.DashboardComponent{
Title: "Public",
Contents: []*config.DashboardComponent{
{Title: "Status", Type: "display"},
},
}
guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
guest.BuildUserAcls(cfg)
assert.True(t, IsAllowedViewDashboard(cfg, guest, dashboard))
assert.True(t, IsAllowedViewDashboard(cfg, guest, nil))
}
func TestIsAllowedViewDashboardAllowDenyAndDefaultFallback(t *testing.T) {
cfg := config.DefaultConfig()
cfg.DefaultPermissions.View = false
cfg.AccessControlLists = []*config.AccessControlList{
{
Name: "admins",
MatchUsernames: []string{"admin"},
Permissions: config.PermissionsList{View: true, Exec: true},
},
}
dashboard := &config.DashboardComponent{
Title: "Services",
Acls: []string{"admins"},
Contents: []*config.DashboardComponent{
{Title: "Status: running", Type: "display"},
},
}
guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
guest.BuildUserAcls(cfg)
admin := &authpublic.AuthenticatedUser{Username: "admin"}
admin.BuildUserAcls(cfg)
assert.False(t, IsAllowedViewDashboard(cfg, guest, dashboard))
assert.True(t, IsAllowedViewDashboard(cfg, admin, dashboard))
cfg.DefaultPermissions.View = true
assert.True(t, IsAllowedViewDashboard(cfg, guest, dashboard),
"when no relevant ACL matches, fall back to defaultPermissions.view")
}
func TestIsAllowedViewDashboardIgnoresAddToEveryAction(t *testing.T) {
cfg := config.DefaultConfig()
cfg.DefaultPermissions.View = false
cfg.AccessControlLists = []*config.AccessControlList{
{
Name: "admins",
MatchUsernames: []string{"admin"},
AddToEveryAction: true,
Permissions: config.PermissionsList{View: true, Exec: true},
},
}
dashboard := &config.DashboardComponent{
Title: "Secret",
Acls: []string{"other"},
Contents: []*config.DashboardComponent{
{Title: "Hidden status", Type: "display"},
},
}
admin := &authpublic.AuthenticatedUser{Username: "admin"}
admin.BuildUserAcls(cfg)
assert.False(t, IsAllowedViewDashboard(cfg, admin, dashboard),
"AddToEveryAction must not grant dashboard view without listing the ACL on the dashboard")
}

View File

@ -0,0 +1,110 @@
package api
import (
"testing"
authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/OliveTin/OliveTin/internal/executor"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func buildDashboardAclTestConfig() *config.Config {
cfg := config.DefaultConfig()
cfg.DefaultPermissions.View = false
cfg.DefaultPermissions.Exec = false
cfg.AccessControlLists = []*config.AccessControlList{
{
Name: "admins",
MatchUsernames: []string{"admin"},
Permissions: config.PermissionsList{View: true, Exec: true},
},
}
cfg.Dashboards = []*config.DashboardComponent{
{
Title: "Public tools",
Contents: []*config.DashboardComponent{
{Title: "Welcome", Type: "display"},
},
},
{
Title: "Services",
Acls: []string{"admins"},
Contents: []*config.DashboardComponent{
{Title: "Status: running", Type: "display"},
},
},
}
return cfg
}
func TestDashboardAclsRootNavAndGetDashboard(t *testing.T) {
cfg := buildDashboardAclTestConfig()
ex := executor.DefaultExecutor(cfg)
api := newServer(ex)
guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
guest.BuildUserAcls(cfg)
admin := &authpublic.AuthenticatedUser{Username: "admin"}
admin.BuildUserAcls(cfg)
guestRoots := api.buildRootDashboards(guest, cfg.Dashboards)
assert.Contains(t, guestRoots, "Public tools")
assert.NotContains(t, guestRoots, "Services")
adminRoots := api.buildRootDashboards(admin, cfg.Dashboards)
assert.Contains(t, adminRoots, "Public tools")
assert.Contains(t, adminRoots, "Services")
guestRR := api.createDashboardRenderRequest(guest, "", "")
assert.Nil(t, renderDashboard(guestRR, "Services"),
"GetDashboard must not leak ACL-restricted dashboard content via deep link")
adminRR := api.createDashboardRenderRequest(admin, "", "")
db := renderDashboard(adminRR, "Services")
require.NotNil(t, db)
assert.Equal(t, "Services", db.Title)
}
func TestDashboardAclsNestedDirectoryDeepLink(t *testing.T) {
cfg := buildDashboardAclTestConfig()
cfg.Dashboards = []*config.DashboardComponent{
{
Title: "Public tools",
Contents: []*config.DashboardComponent{
{Title: "Welcome", Type: "display"},
},
},
{
Title: "Services",
Acls: []string{"admins"},
Contents: []*config.DashboardComponent{
{
Title: "Infrastructure",
Contents: []*config.DashboardComponent{
{Title: "Status: running", Type: "display"},
},
},
},
},
}
ex := executor.DefaultExecutor(cfg)
api := newServer(ex)
guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
guest.BuildUserAcls(cfg)
admin := &authpublic.AuthenticatedUser{Username: "admin"}
admin.BuildUserAcls(cfg)
guestRR := api.createDashboardRenderRequest(guest, "", "")
assert.Nil(t, renderDashboard(guestRR, "Infrastructure"),
"nested directory under ACL-restricted root must not leak via deep link")
adminRR := api.createDashboardRenderRequest(admin, "", "")
db := renderDashboard(adminRR, "Infrastructure")
require.NotNil(t, db)
assert.Equal(t, "Infrastructure", db.Title)
}

View File

@ -52,6 +52,10 @@ func findDashboardByTitle(rr *DashboardRenderRequest, dashboardTitle string) *co
}
func renderDashboardIfValid(dashboard *config.DashboardComponent, rr *DashboardRenderRequest) *apiv1.Dashboard {
if !acl.IsAllowedViewDashboard(rr.cfg, rr.AuthenticatedUser, dashboard) {
return nil
}
if len(dashboard.Contents) == 0 {
logEmptyDashboard(dashboard.Title, rr.AuthenticatedUser.Username)
return nil
@ -71,13 +75,21 @@ func renderDirectoryDashboard(rr *DashboardRenderRequest, dashboardTitle string)
func findDirectoryComponent(rr *DashboardRenderRequest, title string) *config.DashboardComponent {
for _, dashboard := range rr.cfg.Dashboards {
if component := searchDirectoryInComponent(dashboard, title); component != nil {
if component := findDirectoryInRootIfAllowed(rr, dashboard, title); component != nil {
return component
}
}
return nil
}
func findDirectoryInRootIfAllowed(rr *DashboardRenderRequest, root *config.DashboardComponent, title string) *config.DashboardComponent {
if !acl.IsAllowedViewDashboard(rr.cfg, rr.AuthenticatedUser, root) {
return nil
}
return searchDirectoryInComponent(root, title)
}
func searchDirectoryInComponent(component *config.DashboardComponent, title string) *config.DashboardComponent {
if isMatchingDirectory(component, title) {
return component

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

@ -349,13 +349,13 @@ func getUserInfo(cfg *config.Config, client *http.Client, provider *config.OAuth
return ret
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != http.StatusOK {
log.Errorf("Failed to get user data: %v", res.StatusCode)
return ret
}
defer res.Body.Close()
contents, err := io.ReadAll(res.Body)
if err != nil {
@ -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

@ -289,6 +289,7 @@ type DashboardComponent struct {
Entity string `koanf:"entity"`
Icon string `koanf:"icon"`
CssClass string `koanf:"cssClass"`
Acls []string `koanf:"acls"`
InlineAction *Action `koanf:"inlineAction"`
Contents []*DashboardComponent `koanf:"contents"`
}

View File

@ -8,6 +8,7 @@ import (
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestUserLoadingFromConfig(t *testing.T) {
@ -30,13 +31,13 @@ actions:
// Create temporary file
tmpFile, err := os.CreateTemp("", "test_config_*.yaml")
assert.NoError(t, err, "Should create temporary file")
defer os.Remove(tmpFile.Name())
require.NoError(t, err, "Should create temporary file")
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()
require.NoError(t, err, "Should write test config to file")
require.NoError(t, tmpFile.Close())
// Load config using koanf
k := koanf.New(".")
@ -81,12 +82,12 @@ actions:
`
tmpFile, err := os.CreateTemp("", "test_config_empty_*.yaml")
assert.NoError(t, err, "Should create temporary file")
defer os.Remove(tmpFile.Name())
require.NoError(t, err, "Should create temporary file")
defer func() { _ = os.Remove(tmpFile.Name()) }()
_, err = tmpFile.WriteString(testConfig)
assert.NoError(t, err, "Should write test config to file")
tmpFile.Close()
require.NoError(t, err, "Should write test config to file")
require.NoError(t, tmpFile.Close())
k := koanf.New(".")
err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
@ -116,12 +117,12 @@ actions:
`
tmpFile, err := os.CreateTemp("", "test_config_disabled_*.yaml")
assert.NoError(t, err, "Should create temporary file")
defer os.Remove(tmpFile.Name())
require.NoError(t, err, "Should create temporary file")
defer func() { _ = os.Remove(tmpFile.Name()) }()
_, err = tmpFile.WriteString(testConfig)
assert.NoError(t, err, "Should write test config to file")
tmpFile.Close()
require.NoError(t, err, "Should write test config to file")
require.NoError(t, tmpFile.Close())
k := koanf.New(".")
err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
@ -147,12 +148,12 @@ actions:
`
tmpFile, err := os.CreateTemp("", "test_config_no_auth_*.yaml")
assert.NoError(t, err, "Should create temporary file")
defer os.Remove(tmpFile.Name())
require.NoError(t, err, "Should create temporary file")
defer func() { _ = os.Remove(tmpFile.Name()) }()
_, err = tmpFile.WriteString(testConfig)
assert.NoError(t, err, "Should write test config to file")
tmpFile.Close()
require.NoError(t, err, "Should write test config to file")
require.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,11 @@ func watchPath(ctx *watchContext) {
return
}
defer watcher.Close()
defer func() {
if err := watcher.Close(); err != nil {
log.Errorf("Failed to close file watcher: %v", err)
}
}()
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,12 @@ func WriteFile(filename string, out []byte) {
return
}
if err := handle.Close(); err != nil {
log.WithFields(log.Fields{
"error": err,
"filename": filename,
}).Errorf("Failed to close %v", filename)
}
}
err := os.WriteFile(filename, out, 0600)

View File

@ -8,6 +8,8 @@ import (
"path/filepath"
"runtime"
"strings"
log "github.com/sirupsen/logrus"
)
type RuntimeInfo struct {
@ -85,6 +87,14 @@ func getOsReleasePrettyName() string {
return ""
}
defer func() {
if closeErr := handle.Close(); closeErr != nil {
log.WithFields(log.Fields{
"error": closeErr,
}).Warn("Failed to close /etc/os-release")
}
}()
scanner := bufio.NewScanner(handle)
scanner.Split(bufio.ScanLines)
@ -96,7 +106,5 @@ func getOsReleasePrettyName() string {
}
}
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,10 +202,13 @@ func appendFile(path, content string) error {
if err != nil {
return err
}
defer file.Close()
_, err = file.WriteString(content)
return err
_, writeErr := file.WriteString(content)
closeErr := file.Close()
if writeErr != nil {
return writeErr
}
return closeErr
}
func newTestRunState() *testRunState {

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

View File

@ -72,7 +72,8 @@ update_checksum() {
update_checksum "${ZIP_NAME}"
update_checksum "${MSI_NAME}"
# Replace binaries first so a failed checksums upload leaves the draft recoverable.
# Upload binaries first so a failed checksums upload can restore the previous file.
# --clobber overwrites same-named assets (needed if the sign job is re-run).
gh release upload "${TAG}" \
"${DIST_DIR}/${ZIP_NAME}" \
"${DIST_DIR}/${MSI_NAME}" \
@ -87,6 +88,4 @@ if ! gh release upload "${TAG}" "${checksums_path}" --clobber; then
exit 1
fi
gh release edit "${TAG}" --draft=false
echo "Published signed ${ZIP_NAME} and ${MSI_NAME} on release ${TAG}"
echo "Uploaded signed ${ZIP_NAME} and ${MSI_NAME} to release ${TAG}"