James Read há 2 dias atrás
pai
commit
3ebc1e1995
100 ficheiros alterados com 3483 adições e 2143 exclusões
  1. 24 4
      .dockerignore
  2. 55 30
      .github/workflows/build-and-release.yml
  3. 3 0
      .github/workflows/build-buf.yml
  4. 4 2
      .github/workflows/codeql-analysis.yml
  5. 18 0
      .github/workflows/codestyle.yml
  6. 2 2
      .github/workflows/devskim.yml
  7. 8 0
      .github/workflows/docs-antora.yml
  8. 58 0
      .github/workflows/generated.yml
  9. 36 0
      .github/workflows/race.yml
  10. 2 0
      .gitignore
  11. 7 0
      .pre-commit-config.yaml
  12. 6 4
      AGENTS.md
  13. 6 2
      CONTRIBUTING.adoc
  14. 20 3
      Makefile
  15. 1 0
      docs/modules/ROOT/.gitignore
  16. 64 24
      docs/modules/ROOT/check_chevron_links.py
  17. 410 0
      docs/modules/ROOT/check_config_keys.py
  18. 3 1
      docs/modules/ROOT/examples/reverse-proxies/etc/reverse_proxy_nginx_dns.conf
  19. 5 6
      docs/modules/ROOT/pages/action_examples/ssh-manual.adoc
  20. 3 3
      docs/modules/ROOT/pages/advanced_configuration/stylemods.adoc
  21. 2 2
      docs/modules/ROOT/pages/api/method_StartActionByGet.adoc
  22. 1 3
      docs/modules/ROOT/pages/api/start_action.adoc
  23. 31 30
      docs/modules/ROOT/pages/config.adoc
  24. 25 1
      docs/modules/ROOT/pages/entities/intro.adoc
  25. 14 2
      docs/modules/ROOT/pages/entities/json.adoc
  26. 2 1
      docs/modules/ROOT/pages/entities/properties.adoc
  27. 3 2
      docs/modules/ROOT/pages/entities/yaml.adoc
  28. 2 0
      docs/modules/ROOT/pages/logs/saving.adoc
  29. 3 3
      docs/modules/ROOT/pages/reference/release_policy.adoc
  30. 2 1
      docs/modules/ROOT/pages/reverse-proxies/nginx.adoc
  31. 3 3
      docs/modules/ROOT/pages/security/example_login_required.adoc
  32. 3 3
      docs/modules/ROOT/pages/security/oauth2.adoc
  33. 61 6
      docs/modules/ROOT/pages/security/oauth2_authelia.adoc
  34. 6 4
      docs/modules/ROOT/pages/security/oauth2_authentik.adoc
  35. 4 4
      docs/modules/ROOT/pages/security/trusted_header.adoc
  36. 1 2
      docs/modules/ROOT/partials/action_execution/onfileindir_arguments.adoc
  37. 1 3
      docs/modules/ROOT/partials/container_socket.adoc
  38. 1 1
      docs/modules/ROOT/partials/install/container.adoc
  39. 0 25
      frontend/.eslintrc.json
  40. 9 8
      frontend/Makefile
  41. 31 0
      frontend/eslint.config.mjs
  42. 0 2
      frontend/index.html
  43. 1 0
      frontend/js/OutputTerminal.js
  44. 17 38
      frontend/main.js
  45. 635 256
      frontend/package-lock.json
  46. 21 25
      frontend/package.json
  47. 1 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  48. 1 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  49. 73 25
      frontend/resources/vue/ActionButton.vue
  50. 242 246
      frontend/resources/vue/App.vue
  51. 16 4
      frontend/resources/vue/Dashboard.vue
  52. 40 41
      frontend/resources/vue/components/ActionStatusDisplay.vue
  53. 0 64
      frontend/resources/vue/components/Breadcrumbs.vue
  54. 9 5
      frontend/resources/vue/components/ChoiceChecklist.vue
  55. 1 1
      frontend/resources/vue/components/DashboardComponent.vue
  56. 5 1
      frontend/resources/vue/components/EntityDefinitionSection.vue
  57. 226 0
      frontend/resources/vue/components/ExecutionLogsTable.vue
  58. 29 29
      frontend/resources/vue/components/actionIconGlyphHelpers.mjs
  59. 7 7
      frontend/resources/vue/components/actionIconGlyphHelpers.test.mjs
  60. 53 0
      frontend/resources/vue/i18n.js
  61. 16 12
      frontend/resources/vue/router.js
  62. 1 1
      frontend/resources/vue/utils/choiceChecklistHelpers.test.mjs
  63. 23 0
      frontend/resources/vue/utils/localeSelection.js
  64. 22 0
      frontend/resources/vue/utils/localeSelection.test.mjs
  65. 18 18
      frontend/resources/vue/utils/prefilledArguments.test.mjs
  66. 32 0
      frontend/resources/vue/utils/themeLoader.js
  67. 57 0
      frontend/resources/vue/utils/themeLoader.test.mjs
  68. 42 139
      frontend/resources/vue/views/ActionDetailsView.vue
  69. 2 0
      frontend/resources/vue/views/ActionExecConditionsView.vue
  70. 24 6
      frontend/resources/vue/views/ArgumentForm.vue
  71. 66 81
      frontend/resources/vue/views/DiagnosticsView.vue
  72. 19 3
      frontend/resources/vue/views/EntitiesView.vue
  73. 32 41
      frontend/resources/vue/views/EntityDetailsView.vue
  74. 40 16
      frontend/resources/vue/views/ExecutionView.vue
  75. 2 0
      frontend/resources/vue/views/LoginView.vue
  76. 3 13
      frontend/resources/vue/views/LogsCalendarView.vue
  77. 44 153
      frontend/resources/vue/views/LogsListView.vue
  78. 23 116
      frontend/resources/vue/views/LogsQueueView.vue
  79. 2 0
      frontend/resources/vue/views/UserControlPanel.vue
  80. 1 16
      frontend/style.css
  81. 13 19
      frontend/vite.config.mjs
  82. 0 8
      integration-tests/.eslintrc.yml
  83. 11 3
      integration-tests/Makefile
  84. 31 0
      integration-tests/eslint.config.mjs
  85. 163 72
      integration-tests/lib/elements.js
  86. 2 1
      integration-tests/mochaSetup.mjs
  87. 199 468
      integration-tests/package-lock.json
  88. 8 4
      integration-tests/package.json
  89. 6 1
      integration-tests/runner.mjs
  90. 1 1
      integration-tests/scripts/find-flakey-tests-inf.mjs
  91. 1 1
      integration-tests/tests/argumentActionFlash/argumentActionFlash.mjs
  92. 1 1
      integration-tests/tests/argumentIdCollision/argumentIdCollision.mjs
  93. 9 5
      integration-tests/tests/authRequireGuestsToLogin/authRequireGuestsToLogin.mjs
  94. 1 1
      integration-tests/tests/checkbox/checkbox.mjs
  95. 1 1
      integration-tests/tests/checklist/checklist.mjs
  96. 37 0
      integration-tests/tests/confirmation/config.yaml
  97. 161 0
      integration-tests/tests/confirmation/confirmation.mjs
  98. 42 1
      integration-tests/tests/cssClass/cssClass.mjs
  99. 3 8
      integration-tests/tests/dashboardAcls/dashboardAcls.mjs
  100. 7 2
      integration-tests/tests/dashboardsWithBasicFieldsets/dashboardsWithBasicFieldsets.mjs

+ 24 - 4
.dockerignore

@@ -1,4 +1,24 @@
-.eslintrc.json
-.stylelintrc.json
-package.json
-package-lock.json
+.git
+.github
+.cursor
+.vscode
+
+**/node_modules
+**/reports
+**/screenshots
+**/*.log
+**/*.swp
+**/*.swo
+
+build
+dist
+docs
+frontend
+integration-tests
+lang
+proto
+releases
+service
+specs
+tmp
+var/marketing

+ 55 - 30
.github/workflows/build-and-release.yml

@@ -5,14 +5,23 @@ on:
   pull_request:
   pull_request:
     paths:
     paths:
       - '.github/workflows/build-and-release.yml'
       - '.github/workflows/build-and-release.yml'
+      - '.dockerignore'
       - '.goreleaser.yml'
       - '.goreleaser.yml'
+      - '.releaserc.yaml'
       - 'Dockerfile.multiarches'
       - 'Dockerfile.multiarches'
       - 'Dockerfile.singlearch'
       - 'Dockerfile.singlearch'
+      - 'LICENSE'
       - 'Makefile'
       - 'Makefile'
+      - 'config.yaml'
+      - 'examples/backupScript.sh'
       - 'frontend/**'
       - 'frontend/**'
       - 'integration-tests/**'
       - 'integration-tests/**'
+      - 'lang/**'
       - 'proto/**'
       - 'proto/**'
       - 'service/**'
       - 'service/**'
+      - 'var/entities/**'
+      - 'var/helper-actions/**'
+      - 'var/macos/**'
       - 'var/windows/**'
       - 'var/windows/**'
   workflow_dispatch:
   workflow_dispatch:
   push:
   push:
@@ -24,19 +33,34 @@ on:
       - beta
       - beta
     paths:
     paths:
       - '.github/workflows/build-and-release.yml'
       - '.github/workflows/build-and-release.yml'
+      - '.dockerignore'
       - '.goreleaser.yml'
       - '.goreleaser.yml'
+      - '.releaserc.yaml'
       - 'Dockerfile.multiarches'
       - 'Dockerfile.multiarches'
       - 'Dockerfile.singlearch'
       - 'Dockerfile.singlearch'
+      - 'LICENSE'
       - 'Makefile'
       - 'Makefile'
+      - 'config.yaml'
+      - 'examples/backupScript.sh'
       - 'frontend/**'
       - 'frontend/**'
       - 'integration-tests/**'
       - 'integration-tests/**'
+      - 'lang/**'
       - 'proto/**'
       - 'proto/**'
       - 'service/**'
       - 'service/**'
+      - 'var/entities/**'
+      - 'var/helper-actions/**'
+      - 'var/macos/**'
       - 'var/windows/**'
       - 'var/windows/**'
 
 
+permissions:
+  contents: read
+
 jobs:
 jobs:
   build:
   build:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
+    env:
+      # semantic-release only publishes from main (.releaserc.yaml); goreleaser runs in publishCmd.
+      WILL_PUBLISH: ${{ github.ref == 'refs/heads/main' && github.ref_type != 'tag' && github.event_name != 'pull_request' }}
     outputs:
     outputs:
       new_release_published: ${{ steps.release.outputs.new_release_published }}
       new_release_published: ${{ steps.release.outputs.new_release_published }}
       new_release_git_tag: ${{ steps.release.outputs.new_release_git_tag }}
       new_release_git_tag: ${{ steps.release.outputs.new_release_git_tag }}
@@ -46,28 +70,24 @@ jobs:
       - name: Checkout
       - name: Checkout
         uses: actions/checkout@v6
         uses: actions/checkout@v6
         with:
         with:
-          fetch-depth: 0
+          fetch-depth: ${{ env.WILL_PUBLISH == 'true' && '0' || '1' }}
 
 
       - name: Set up QEMU
       - name: Set up QEMU
+        if: env.WILL_PUBLISH == 'true'
         id: qemu
         id: qemu
         uses: docker/setup-qemu-action@v4
         uses: docker/setup-qemu-action@v4
         with:
         with:
           image: tonistiigi/binfmt:latest
           image: tonistiigi/binfmt:latest
           platforms: arm64,arm
           platforms: arm64,arm
 
 
-      - name: Setup node (npm cache)
-        if: github.event_name != 'pull_request'
-        uses: actions/setup-node@v6.4.0
-        with:
-          node-version: '22'
-          cache: 'npm'
-          cache-dependency-path: frontend/package-lock.json
-
       - name: Setup node
       - name: Setup node
-        if: github.event_name == 'pull_request'
         uses: actions/setup-node@v6.4.0
         uses: actions/setup-node@v6.4.0
         with:
         with:
           node-version: '22'
           node-version: '22'
+          cache: 'npm'
+          cache-dependency-path: |
+            frontend/package-lock.json
+            integration-tests/package-lock.json
 
 
       - name: Setup Go
       - name: Setup Go
         uses: actions/setup-go@v6
         uses: actions/setup-go@v6
@@ -80,14 +100,14 @@ jobs:
         run: go version
         run: go version
 
 
       - name: Login to Docker Hub
       - name: Login to Docker Hub
-        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
+        if: env.WILL_PUBLISH == 'true'
         uses: docker/login-action@v4
         uses: docker/login-action@v4
         with:
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_KEY }}
           password: ${{ secrets.DOCKERHUB_KEY }}
 
 
       - name: Login to ghcr
       - name: Login to ghcr
-        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
+        if: env.WILL_PUBLISH == 'true'
         uses: docker/login-action@v4
         uses: docker/login-action@v4
         with:
         with:
           registry: ghcr.io
           registry: ghcr.io
@@ -98,24 +118,29 @@ jobs:
         run: |
         run: |
           echo "DATE=$(date +'%Y-%m-%d')" >> "$GITHUB_ENV"
           echo "DATE=$(date +'%Y-%m-%d')" >> "$GITHUB_ENV"
 
 
-      - name: make webui
-        run: make -w webui-dist
-
-      - name: unit tests
-        run: make -w service-unittests
-
-      - name: frontend unit tests
-        run: make -w frontend-unittests
-
-      - name: build service
-        run: make -w service
+      - name: build and unit tests
+        run: |
+          make -w webui-dist &
+          webui_pid=$!
+          make -w service-unittests &
+          service_tests_pid=$!
+          webui_status=0
+          service_tests_status=0
+          wait "$webui_pid" || webui_status=$?
+          wait "$service_tests_pid" || service_tests_status=$?
+          if (( webui_status != 0 || service_tests_status != 0 )); then
+            exit 1
+          fi
+          make -w frontend-unittests
 
 
       - name: integration tests
       - name: integration tests
+        env:
+          SKIP_WEBUI: 1
         run: cd integration-tests && make -w
         run: cd integration-tests && make -w
 
 
       - name: Archive integration tests
       - name: Archive integration tests
         uses: actions/upload-artifact@v7
         uses: actions/upload-artifact@v7
-        if: always()
+        if: failure()
         with:
         with:
           name: "OliveTin-integration-tests-${{ env.DATE }}-${{ github.sha }}"
           name: "OliveTin-integration-tests-${{ env.DATE }}-${{ github.sha }}"
           path: |
           path: |
@@ -123,21 +148,21 @@ jobs:
             !integration-tests/node_modules
             !integration-tests/node_modules
 
 
       - name: Install wixl and msitools
       - name: Install wixl and msitools
-        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
+        if: env.WILL_PUBLISH == 'true'
         run: sudo apt-get update && sudo apt-get install -y wixl msitools
         run: sudo apt-get update && sudo apt-get install -y wixl msitools
 
 
       - name: Install goreleaser
       - name: Install goreleaser
-        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
+        if: env.WILL_PUBLISH == 'true'
         uses: goreleaser/goreleaser-action@v7
         uses: goreleaser/goreleaser-action@v7
         with:
         with:
           install-only: true
           install-only: true
 
 
       - name: Set up Docker Buildx
       - name: Set up Docker Buildx
-        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
+        if: env.WILL_PUBLISH == 'true'
         uses: docker/setup-buildx-action@v4
         uses: docker/setup-buildx-action@v4
 
 
       - name: Verify macOS signing certificate chain
       - name: Verify macOS signing certificate chain
-        if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
+        if: env.WILL_PUBLISH == 'true'
         env:
         env:
           MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
           MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
           MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
           MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
@@ -145,7 +170,7 @@ jobs:
 
 
       - name: release
       - name: release
         id: release
         id: release
-        if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
+        if: env.WILL_PUBLISH == 'true'
         uses: cycjimmy/semantic-release-action@v5
         uses: cycjimmy/semantic-release-action@v5
         with:
         with:
           extra_plugins: |
           extra_plugins: |
@@ -182,7 +207,7 @@ jobs:
           if-no-files-found: error
           if-no-files-found: error
 
 
       - name: Archive binaries
       - name: Archive binaries
-        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
+        if: env.WILL_PUBLISH == 'true'
         uses: actions/upload-artifact@v7
         uses: actions/upload-artifact@v7
         with:
         with:
           name: "OliveTin-snapshot-${{ env.DATE }}-${{ github.sha }}"
           name: "OliveTin-snapshot-${{ env.DATE }}-${{ github.sha }}"

+ 3 - 0
.github/workflows/build-buf.yml

@@ -7,6 +7,9 @@ on:
 
 
   pull_request:
   pull_request:
     types: [opened, synchronize, reopened, labeled, unlabeled]
     types: [opened, synchronize, reopened, labeled, unlabeled]
+    paths:
+      - '.github/workflows/build-buf.yml'
+      - 'proto/**'
   delete:
   delete:
 permissions:
 permissions:
   contents: read
   contents: read

+ 4 - 2
.github/workflows/codeql-analysis.yml

@@ -18,17 +18,19 @@ on:
       - '.github/workflows/codeql-analysis.yml'
       - '.github/workflows/codeql-analysis.yml'
       - 'frontend/**'
       - 'frontend/**'
       - 'integration-tests/**'
       - 'integration-tests/**'
+      - 'lang/**'
       - 'proto/**'
       - 'proto/**'
       - 'service/**'
       - 'service/**'
-    branches: [main]
+    branches: [main, next]
   pull_request:
   pull_request:
     paths:
     paths:
       - '.github/workflows/codeql-analysis.yml'
       - '.github/workflows/codeql-analysis.yml'
       - 'frontend/**'
       - 'frontend/**'
       - 'integration-tests/**'
       - 'integration-tests/**'
+      - 'lang/**'
       - 'proto/**'
       - 'proto/**'
       - 'service/**'
       - 'service/**'
-    branches: [main]
+    branches: [main, next]
   schedule:
   schedule:
     - cron: '25 10 * * 5'
     - cron: '25 10 * * 5'
 
 

+ 18 - 0
.github/workflows/codestyle.yml

@@ -5,6 +5,17 @@ on:
   push:
   push:
     paths:
     paths:
       - '.github/workflows/codestyle.yml'
       - '.github/workflows/codestyle.yml'
+      - 'Makefile'
+      - 'frontend/**'
+      - 'integration-tests/**'
+      - 'proto/**'
+      - 'service/**'
+  pull_request:
+    branches:
+      - next
+    paths:
+      - '.github/workflows/codestyle.yml'
+      - 'Makefile'
       - 'frontend/**'
       - 'frontend/**'
       - 'integration-tests/**'
       - 'integration-tests/**'
       - 'proto/**'
       - 'proto/**'
@@ -35,9 +46,16 @@ jobs:
         uses: actions/setup-node@v4
         uses: actions/setup-node@v4
         with:
         with:
           node-version: '22'
           node-version: '22'
+          cache: 'npm'
+          cache-dependency-path: |
+            frontend/package-lock.json
+            integration-tests/package-lock.json
 
 
       - name: frontend
       - name: frontend
         run: make -wC frontend codestyle
         run: make -wC frontend codestyle
 
 
       - name: frontend unit tests
       - name: frontend unit tests
         run: make -wC frontend unittests
         run: make -wC frontend unittests
+
+      - name: integration tests codestyle
+        run: make -wC integration-tests codestyle

+ 2 - 2
.github/workflows/devskim.yml

@@ -17,7 +17,7 @@ on:
       - 'integration-tests/**'
       - 'integration-tests/**'
       - 'proto/**'
       - 'proto/**'
       - 'service/**'
       - 'service/**'
-    branches: [ "main" ]
+    branches: [ "main", "next" ]
   pull_request:
   pull_request:
     paths:
     paths:
       - '.github/workflows/devskim.yml'
       - '.github/workflows/devskim.yml'
@@ -29,7 +29,7 @@ on:
       - 'integration-tests/**'
       - 'integration-tests/**'
       - 'proto/**'
       - 'proto/**'
       - 'service/**'
       - 'service/**'
-    branches: [ "main" ]
+    branches: [ "main", "next" ]
   schedule:
   schedule:
     - cron: '34 21 * * 2'
     - cron: '34 21 * * 2'
 
 

+ 8 - 0
.github/workflows/docs-antora.yml

@@ -3,12 +3,14 @@ on:
   push:
   push:
     paths:
     paths:
       - 'docs/**'
       - 'docs/**'
+      - 'service/internal/config/config.go'
       - 'local-antora-playbook.yml'
       - 'local-antora-playbook.yml'
       - 'local-antora-playbook-ci.yml'
       - 'local-antora-playbook-ci.yml'
       - '.github/workflows/docs-antora.yml'
       - '.github/workflows/docs-antora.yml'
   pull_request:
   pull_request:
     paths:
     paths:
       - 'docs/**'
       - 'docs/**'
+      - 'service/internal/config/config.go'
       - 'local-antora-playbook.yml'
       - 'local-antora-playbook.yml'
       - 'local-antora-playbook-ci.yml'
       - 'local-antora-playbook-ci.yml'
       - '.github/workflows/docs-antora.yml'
       - '.github/workflows/docs-antora.yml'
@@ -30,6 +32,12 @@ jobs:
       - name: Install Antora toolchain
       - name: Install Antora toolchain
         run: npm i antora@3.1.14 asciidoctor-kroki@0.18.1 @asciidoctor/tabs@1.0.0-beta.6
         run: npm i antora@3.1.14 asciidoctor-kroki@0.18.1 @asciidoctor/tabs@1.0.0-beta.6
 
 
+      - name: Check docs config key casing
+        run: python3 docs/modules/ROOT/check_config_keys.py
+
+      - name: Check docs chevron links
+        run: python3 docs/modules/ROOT/check_chevron_links.py
+
       - name: Generate docs site (smoke)
       - name: Generate docs site (smoke)
         run: npx antora local-antora-playbook-ci.yml --log-level info
         run: npx antora local-antora-playbook-ci.yml --log-level info
 
 

+ 58 - 0
.github/workflows/generated.yml

@@ -0,0 +1,58 @@
+name: Generated source checks
+
+on:
+  push:
+    branches:
+      - main
+      - next
+    paths:
+      - '.github/workflows/generated.yml'
+      - 'Makefile'
+      - 'frontend/resources/scripts/gen/**'
+      - 'lang/**'
+      - 'proto/**'
+      - 'service/gen/**'
+      - 'service/generate.go'
+      - 'service/go.mod'
+      - 'service/go.sum'
+      - 'service/Makefile'
+      - 'service/tools.go'
+  pull_request:
+    branches:
+      - next
+    paths:
+      - '.github/workflows/generated.yml'
+      - 'Makefile'
+      - 'frontend/resources/scripts/gen/**'
+      - 'lang/**'
+      - 'proto/**'
+      - 'service/gen/**'
+      - 'service/generate.go'
+      - 'service/go.mod'
+      - 'service/go.sum'
+      - 'service/Makefile'
+      - 'service/tools.go'
+
+permissions:
+  contents: read
+
+jobs:
+  generated:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+        with:
+          persist-credentials: false
+
+      - name: Setup Go
+        uses: actions/setup-go@v5
+        with:
+          go-version-file: 'service/go.mod'
+          cache: true
+          cache-dependency-path: |
+            service/go.sum
+            lang/go.sum
+
+      - name: Check generated sources
+        run: make generated-check

+ 36 - 0
.github/workflows/race.yml

@@ -0,0 +1,36 @@
+name: Go race detector
+
+on:
+  push:
+    branches:
+      - main
+      - next
+    paths:
+      - '.github/workflows/race.yml'
+      - 'service/**'
+  pull_request:
+    branches:
+      - next
+    paths:
+      - '.github/workflows/race.yml'
+      - 'service/**'
+
+permissions:
+  contents: read
+
+jobs:
+  race:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup Go
+        uses: actions/setup-go@v5
+        with:
+          go-version-file: 'service/go.mod'
+          cache: true
+          cache-dependency-path: 'service/go.mod'
+
+      - name: Run race detector
+        run: make -wC service unittests-race

+ 2 - 0
.gitignore

@@ -11,6 +11,7 @@ installation-id.txt
 tmp/
 tmp/
 frontend/dist/
 frontend/dist/
 frontend/node_modules
 frontend/node_modules
+integration-tests/node_modules/
 custom-frontend
 custom-frontend
 integration-tests/screenshots/
 integration-tests/screenshots/
 integration-tests/flakey-test-runs.log
 integration-tests/flakey-test-runs.log
@@ -28,4 +29,5 @@ sessions.yaml
 docs/build/
 docs/build/
 build/
 build/
 Binary/
 Binary/
+lang/langtool
 .cursor
 .cursor

+ 7 - 0
.pre-commit-config.yaml

@@ -33,6 +33,13 @@ repos:
 
 
   - repo: local
   - repo: local
     hooks:
     hooks:
+      - id: docs-check
+        name: docs-check
+        entry: make docs-check
+        language: system
+        pass_filenames: false
+        files: ^(docs/|service/internal/config/config\.go|Makefile)
+
       - id: service-codestyle
       - id: service-codestyle
         name: service-codestyle
         name: service-codestyle
         entry: make service-codestyle
         entry: make service-codestyle

+ 6 - 4
AGENTS.md

@@ -21,10 +21,12 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
 - Unit tests (Go):
 - Unit tests (Go):
   - From repo root: `cd service && make unittests`
   - From repo root: `cd service && make unittests`
 - Code style (after editing code in `service/`):
 - Code style (after editing code in `service/`):
-  - From repo root: `cd service && make codestyle`
+  - From repo root: `cd service && make codestyle` (runs `go fmt` and `golangci-lint`; install linter via `make go-tools`)
+- Documentation checks:
+  - From repo root: `make docs-check` (validates documented config key casing and local AsciiDoc anchor links)
 - Integration tests (Mocha + Selenium):
 - Integration tests (Mocha + Selenium):
-  - Single test: `cd integration-tests && npx --yes mocha test/general.mjs`
-  - All tests: `cd integration-tests && npx --yes mocha`
+  - All tests: `make it` (from repo root; builds webui + service binary, then runs Mocha)
+  - Single test: `cd integration-tests && npx --yes mocha tests/general/general.mjs`
 
 
 ### Test Notes and Gotchas
 ### Test Notes and Gotchas
 - The top-level Makefile does not expose `unittests`; use `cd service && make unittests`.
 - The top-level Makefile does not expose `unittests`; use `cd service && make unittests`.
@@ -44,7 +46,7 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
 - Do not swallow errors; propagate or log meaningfully.
 - Do not swallow errors; propagate or log meaningfully.
 - Match existing formatting; avoid unrelated reformatting.
 - Match existing formatting; avoid unrelated reformatting.
 - Be safe around nils in executor steps (e.g., guard `req.Binding` and `req.Binding.Action`).
 - Be safe around nils in executor steps (e.g., guard `req.Binding` and `req.Binding.Action`).
-- Cyclomatic complexity over 4 is not permitted.
+- Cyclomatic complexity over 4 is not permitted (`gocyclo` `min-complexity: 5` in `service/.golangci.yml`).
 
 
 ### API and Execution Flow (High-level)
 ### API and Execution Flow (High-level)
 1. Client calls Connect RPC (e.g., `Init`, `GetDashboard`, `StartAction`).
 1. Client calls Connect RPC (e.g., `Init`, `GetDashboard`, `StartAction`).

+ 6 - 2
CONTRIBUTING.adoc

@@ -46,12 +46,15 @@ pre-commit install
 pre-commit install --hook-type pre-push
 pre-commit install --hook-type pre-push
 
 
 # Step3: compile binary for current dev env (OS, ARCH)
 # Step3: compile binary for current dev env (OS, ARCH)
-# `make proto` will also run `make go-tools`, which installs "buf". This binary
-# will be put in your GOPATH/bin/, which should be on your path. buf is used to
+# `make proto` installs pinned protobuf tools in GOPATH/bin/ and uses buf to
 # generate the protobuf / Connect RPC stubs.
 # generate the protobuf / Connect RPC stubs.
 make proto
 make proto
 make
 make
 ./OliveTin
 ./OliveTin
+
+Go codestyle checks (`make -wC service codestyle`) use golangci-lint. Install it
+with `make -wC service go-tools` if `golangci-lint` is not already on your PATH.
+Lint rules live in `service/.golangci.yml`.
 ```
 ```
 
 
 === Getting started to contribute;
 === Getting started to contribute;
@@ -60,6 +63,7 @@ The project layout is reasonably straightforward;
 
 
 * See the `Makefile` for common targets. This project was originally created on top of Fedora, but it should be usable on Debian/your faveourite distro with minor changes (if any).
 * See the `Makefile` for common targets. This project was originally created on top of Fedora, but it should be usable on Debian/your faveourite distro with minor changes (if any).
 * End-user documentation (AsciiDoc for link:https://docs.olivetin.app[docs.olivetin.app]) lives in `docs/` as an Antora component; the published site is built from the separate link:https://github.com/OliveTin/docs.olivetin.app[docs.olivetin.app] repository.
 * End-user documentation (AsciiDoc for link:https://docs.olivetin.app[docs.olivetin.app]) lives in `docs/` as an Antora component; the published site is built from the separate link:https://github.com/OliveTin/docs.olivetin.app[docs.olivetin.app] repository.
+* Run `make docs-check` after changing documentation or the service configuration schema.
 * The API is defined in protobuf+Connect RPC - you will need to `make proto`.
 * The API is defined in protobuf+Connect RPC - you will need to `make proto`.
 * The Go daemon is built from the `cmd` and `internal` directories mostly.
 * The Go daemon is built from the `cmd` and `internal` directories mostly.
 * The webui is just a single page application with a bit of Javascript in the `webui` directory. This can happily be hosted on another webserver.
 * The webui is just a single page application with a bit of Javascript in the `webui` directory. This can happily be hosted on another webserver.

+ 20 - 3
Makefile

@@ -1,7 +1,9 @@
 define delete-files
 define delete-files
-	python -c "import shutil;shutil.rmtree('$(1)', ignore_errors=True)"
+	python3 -c "import shutil;shutil.rmtree('$(1)', ignore_errors=True)"
 endef
 endef
 
 
+default: proto service webui-dist
+
 service:
 service:
 	$(MAKE) -wC service
 	$(MAKE) -wC service
 
 
@@ -26,15 +28,30 @@ frontend-codestyle:
 frontend-unittests:
 frontend-unittests:
 	$(MAKE) -wC frontend unittests
 	$(MAKE) -wC frontend unittests
 
 
+docs-check:
+	python3 docs/modules/ROOT/check_config_keys.py
+	python3 docs/modules/ROOT/check_chevron_links.py
+
 it:
 it:
 	$(MAKE) -wC integration-tests
 	$(MAKE) -wC integration-tests
 
 
 go-tools:
 go-tools:
 	$(MAKE) -wC service go-tools
 	$(MAKE) -wC service go-tools
 
 
-proto: go-tools
+proto-tools:
+	$(MAKE) -wC service proto-tools
+
+proto: proto-tools
 	$(MAKE) -wC proto
 	$(MAKE) -wC proto
 
 
+lang-generate:
+	$(MAKE) -wC lang
+
+generated-check: proto lang-generate
+	git diff --exit-code -- service/gen frontend/resources/scripts/gen lang/generated
+	@untracked="$$(git ls-files --others --exclude-standard -- service/gen frontend/resources/scripts/gen lang/generated)"; \
+	test -z "$$untracked" || { printf 'Untracked generated files:\n%s\n' "$$untracked"; exit 1; }
+
 dist:
 dist:
 	echo "dist noop"
 	echo "dist noop"
 
 
@@ -79,4 +96,4 @@ config-tool:
 devcheck:
 devcheck:
 	python3 scripts/devcheck.py $(ARGS)
 	python3 scripts/devcheck.py $(ARGS)
 
 
-.PHONY: proto service windows-resources windows-msi frontend-unittests devcheck
+.PHONY: proto proto-tools lang-generate generated-check default service windows-resources windows-msi frontend-unittests docs-check it devcheck

+ 1 - 0
docs/modules/ROOT/.gitignore

@@ -0,0 +1 @@
+__pycache__

+ 64 - 24
docs/modules/ROOT/check_chevron_links.py

@@ -1,40 +1,80 @@
 #!/usr/bin/env python3
 #!/usr/bin/env python3
 
 
-import glob
+# Find <<anchor>> links whose target is not defined on the assembled page.
+# include::partial$... directives are expanded so chevrons inside partials are
+# checked against every page that includes them.
+
+from pathlib import Path
 import re
 import re
+import sys
+
+ROOT = Path(__file__).resolve().parent
+PAGES = ROOT / "pages"
+PARTIALS = ROOT / "partials"
+PARTIALS_ROOT = PARTIALS.resolve()
+
+INCLUDE_RE = re.compile(r"include::partial\$([^\[\]]+)\[([^\]]*)\]")
+CHEVRON_RE = re.compile(r"<<([^,>]+)(?:,[^>]*)?>>")
+
+
+def expand_partials(content, stack):
+    def replace(match):
+        rel = match.group(1)
+        partial_path = (PARTIALS / rel).resolve()
+
+        if not partial_path.is_relative_to(PARTIALS_ROOT):
+            return ""
+
+        if not partial_path.is_file() or partial_path in stack:
+            return ""
+
+        included = partial_path.read_text()
+        return expand_partials(included, stack | {partial_path})
+
+    return INCLUDE_RE.sub(replace, content)
+
+
+def count_anchors(content, anchor_id):
+    escaped = re.escape(anchor_id)
+    pattern = rf"\[(?:#|\[){escaped}(?=[,\]])"
+    return len(re.findall(pattern, content))
+
+
+def find_unresolved(content):
+    unresolved = []
+
+    for match in CHEVRON_RE.finditer(content):
+        anchor_id = match.group(1).strip()
 
 
-nav_file = open('nav.adoc', 'r')
-nav_string = nav_file.read()
+        if count_anchors(content, anchor_id) == 1:
+            continue
 
 
-adoc_files = glob.glob('pages/**/*.adoc', recursive=True)
+        if anchor_id not in unresolved:
+            unresolved.append(anchor_id)
 
 
-filelist = dict()
+    return unresolved
 
 
-for file in adoc_files:
-    with open(file, 'r') as handle:
-        content = handle.read()
 
 
-        matches = re.findall(r'<<(.*?),?([\w\- ]+)>>', content)
+def main():
+    filelist = {}
 
 
-        for match in matches:
-            m = match
+    for page in sorted(PAGES.rglob("*.adoc")):
+        assembled = expand_partials(page.read_text(), set())
+        missing = find_unresolved(assembled)
 
 
-            if match[0] == "":
-                m = match[1]
-            else:
-                m = match[0]
+        if missing:
+            filelist[str(page.relative_to(ROOT))] = missing
 
 
-            if content.count("#" + m) != 1:
-                if file not in filelist:
-                    filelist[file] = list()
+    print("Files:", len(filelist))
 
 
-                filelist[file].append(m)
+    for file in filelist:
+        print(file)
 
 
+        for match in filelist[file]:
+            print("\t", match)
 
 
-print("Files:", len(filelist))
+    sys.exit(1 if filelist else 0)
 
 
-for file in filelist.keys():
-    print(file)
 
 
-    for match in filelist[file]:
-        print("\t", match)
+if __name__ == "__main__":
+    main()

+ 410 - 0
docs/modules/ROOT/check_config_keys.py

@@ -0,0 +1,410 @@
+#!/usr/bin/env python3
+
+# Find config option names in docs that do not match YAML keys from config.go.
+#
+# OliveTin config uses camelCase koanf tags (see service/internal/config/config.go).
+# Docs sometimes use Go struct field names (PascalCase) or other wrong spellings.
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+import re
+import sys
+
+ROOT = Path(__file__).resolve().parent
+REPO_ROOT = ROOT.parents[2]
+CONFIG_GO = REPO_ROOT / "service/internal/config/config.go"
+DOC_DIRS = (ROOT / "pages", ROOT / "partials")
+
+STRUCT_START_RE = re.compile(r"^type (\w+) struct\b")
+FIELD_RE = re.compile(
+    r'^\s+(\w+)\s+([^`]+?)`koanf:"([^"]+)"`',
+)
+BACKTICK_RE = re.compile(r"`([^`]+)`")
+YAML_BLOCK_RE = re.compile(
+    r"\[source,yaml\][^\n]*\n----\n(.*?)\n----",
+    re.DOTALL,
+)
+YAML_KEY_RE = re.compile(r"^(\s*)([A-Za-z][\w]*)\s*:", re.MULTILINE)
+
+SKIP_BACKTICK = frozenset({
+    "Insecure*",
+})
+
+SKIP_YAML_PREFIXES = frozenset({
+    "actions",
+    "dashboards",
+    "entities",
+    "title",
+    "shell",
+    "icon",
+    "arguments",
+    "name",
+    "type",
+    "default",
+    "description",
+    "choices",
+    "value",
+    "permissions",
+    "view",
+    "exec",
+    "logs",
+    "kill",
+    "matchUsergroups",
+    "matchUsernames",
+    "policy",
+    "users",
+    "username",
+    "password",
+    "usergroup",
+    "enabled",
+    "acls",
+    "groups",
+    "maxConcurrent",
+    "timeout",
+    "onclick",
+    "execOnStartup",
+    "maxRate",
+    "limit",
+    "duration",
+    "id",
+    "hidden",
+    "category",
+    "contents",
+    "file",
+    "properties",
+    "inlineAction",
+    "resultsDirectory",
+    "outputDirectory",
+    "directory",
+    "showDiagnostics",
+    "showLogList",
+    "showVersionNumber",
+    "headerSearch",
+    "defaultGoMetrics",
+    "contentSecurityPolicy",
+    "xFrameOptions",
+    "headerContentSecurityPolicy",
+    "headerXContentTypeOptions",
+    "headerXFrameOptions",
+    "forceSecureCookies",
+    "clientId",
+    "clientSecret",
+    "authUrl",
+    "tokenUrl",
+    "whoamiUrl",
+    "scopes",
+    "addToUsergroup",
+    "userGroupField",
+    "usernameField",
+    "certBundlePath",
+    "callbackTimeout",
+    "insecureSkipVerify",
+    "secret",
+    "authType",
+    "authHeader",
+    "matchHeaders",
+    "matchPath",
+    "matchQuery",
+    "extract",
+    "template",
+    "justification",
+    "apiKey",
+    "addToEveryAction",
+    "execOnCron",
+    "execOnCalendarFile",
+    "shellAfterCompleted",
+    "execOnWebhook",
+    "triggers",
+    "exec",
+    "execOnFileCreatedInDir",
+    "execOnFileChangedInDir",
+    "entity",
+    "popupOnStart",
+    "saveLogs",
+    "suggestions",
+    "suggestionsBrowserKey",
+    "rejectNull",
+    "queueSize",
+    "cssClass",
+    "url",
+    "target",
+    "styleMods",
+    "include",
+    "bannerCss",
+    "bannerMessage",
+    "serviceHostMode",
+    "themeCacheDisabled",
+    "checkForUpdates",
+    "logHistoryPageSize",
+    "additionalNavigationLinks",
+    "actionGroups",
+    "authOAuth2Providers",
+    "authOAuth2RedirectUrl",
+    "authJwtHmacSecret",
+})
+
+
+@dataclass(frozen=True)
+class Issue:
+    path: str
+    line: int
+    found: str
+    expected: str
+    kind: str
+
+
+def parse_structs(content: str) -> dict[str, list[tuple[str, str, str]]]:
+    structs: dict[str, list[tuple[str, str, str]]] = {}
+    current: str | None = None
+
+    for line in content.splitlines():
+        struct_match = STRUCT_START_RE.match(line)
+        if struct_match:
+            current = struct_match.group(1)
+            structs[current] = []
+            continue
+
+        if current is None:
+            continue
+
+        if line.strip() == "}":
+            current = None
+            continue
+
+        field_match = FIELD_RE.match(line)
+        if not field_match:
+            continue
+
+        field_name, field_type, koanf_tag = field_match.groups()
+        if koanf_tag == "-":
+            continue
+
+        structs[current].append((field_name, field_type.strip(), koanf_tag.strip()))
+
+    return structs
+
+
+def is_nested_struct(field_type: str, structs: dict[str, list[tuple[str, str, str]]]) -> str | None:
+    inner = field_type.removeprefix("[]").removeprefix("*").strip()
+    if inner in structs and inner not in {
+        "Action",
+        "EntityFile",
+        "AccessControlList",
+        "DashboardComponent",
+        "NavigationLink",
+        "OAuth2Provider",
+        "LocalUser",
+        "ActionArgument",
+        "ActionArgumentChoice",
+        "RateSpec",
+        "WebhookConfig",
+        "EntityProperty",
+        "ActionGroup",
+    }:
+        return inner
+    return None
+
+
+def collect_config_keys(
+    structs: dict[str, list[tuple[str, str, str]]],
+) -> tuple[frozenset[str], dict[str, str]]:
+    valid: set[str] = set()
+    aliases: dict[str, str] = {}
+
+    def walk(type_name: str, prefix: str = "") -> None:
+        for field_name, _field_type, koanf_tag in structs.get(type_name, []):
+            path = f"{prefix}.{koanf_tag}" if prefix else koanf_tag
+            valid.add(path)
+
+            if field_name != koanf_tag:
+                aliases[field_name] = koanf_tag
+                if prefix:
+                    aliases[f"{prefix}.{field_name}"] = path
+
+            nested = is_nested_struct(_field_type, structs)
+            if nested:
+                walk(nested, path)
+
+    walk("Config")
+    return frozenset(valid), aliases
+
+
+def camelize_path(key: str) -> str:
+    parts = []
+    for part in key.split("."):
+        if part and part[0].isupper():
+            parts.append(part[0].lower() + part[1:])
+        else:
+            parts.append(part)
+    return ".".join(parts)
+
+
+def looks_like_config_key(key: str) -> bool:
+    if not key or key in SKIP_BACKTICK:
+        return False
+    if "*" in key or " " in key or "/" in key or ":" in key:
+        return False
+    return bool(re.fullmatch(r"[A-Za-z][\w.]*", key))
+
+
+def has_internal_uppercase(key: str) -> bool:
+    if "." in key:
+        return any(has_internal_uppercase(part) for part in key.split("."))
+    return any(char.isupper() for char in key[1:])
+
+
+def case_insensitive_match(key: str, valid: frozenset[str]) -> str | None:
+    matches = [candidate for candidate in valid if candidate.lower() == key.lower()]
+    if len(matches) == 1:
+        return matches[0]
+    return None
+
+
+def resolve_key(
+    key: str,
+    valid: frozenset[str],
+    aliases: dict[str, str],
+    *,
+    allow_case_insensitive: bool = False,
+) -> str | None:
+    if key in valid:
+        return None
+
+    if key in aliases and key != aliases[key]:
+        return aliases[key]
+
+    if allow_case_insensitive:
+        matched = case_insensitive_match(key, valid)
+        if matched is not None and matched != key:
+            return matched
+
+    if not has_internal_uppercase(key):
+        return None
+
+    camelized = camelize_path(key)
+    if camelized in valid and key != camelized:
+        return camelized
+
+    return None
+
+
+def scan_backticks(
+    rel_path: str,
+    content: str,
+    valid: frozenset[str],
+    aliases: dict[str, str],
+) -> list[Issue]:
+    issues: list[Issue] = []
+
+    for line_number, line in enumerate(content.splitlines(), start=1):
+        for match in BACKTICK_RE.finditer(line):
+            key = match.group(1).strip()
+            if not looks_like_config_key(key):
+                continue
+
+            expected = resolve_key(key, valid, aliases)
+            if expected is None:
+                continue
+
+            # Backticks often label UI sections that share a name with config keys.
+            if expected == "actions" and key == "Actions":
+                continue
+
+            issues.append(
+                Issue(
+                    path=rel_path,
+                    line=line_number,
+                    found=key,
+                    expected=expected,
+                    kind="backtick",
+                )
+            )
+
+    return issues
+
+
+def scan_yaml_blocks(
+    rel_path: str,
+    content: str,
+    valid: frozenset[str],
+    aliases: dict[str, str],
+) -> list[Issue]:
+    issues: list[Issue] = []
+
+    for block in YAML_BLOCK_RE.finditer(content):
+        block_text = block.group(1)
+        block_start = block.start(1)
+
+        for match in YAML_KEY_RE.finditer(block_text):
+            indent = len(match.group(1).replace("\t", "    "))
+            key = match.group(2)
+
+            if indent != 0:
+                continue
+            if key in SKIP_YAML_PREFIXES:
+                continue
+            if key in valid:
+                continue
+
+            expected = resolve_key(
+                key,
+                valid,
+                aliases,
+                allow_case_insensitive=True,
+            )
+            if expected is None:
+                continue
+
+            issues.append(
+                Issue(
+                    path=rel_path,
+                    line=content.count("\n", 0, block_start + match.start()) + 1,
+                    found=key,
+                    expected=expected,
+                    kind="yaml",
+                )
+            )
+
+    return issues
+
+
+def iter_doc_files() -> list[Path]:
+    files: list[Path] = []
+    for doc_dir in DOC_DIRS:
+        files.extend(sorted(doc_dir.rglob("*.adoc")))
+    return files
+
+
+def main() -> int:
+    if not CONFIG_GO.is_file():
+        print(f"config.go not found: {CONFIG_GO}", file=sys.stderr)
+        return 2
+
+    structs = parse_structs(CONFIG_GO.read_text())
+    valid, aliases = collect_config_keys(structs)
+
+    issues: list[Issue] = []
+    for doc_path in iter_doc_files():
+        content = doc_path.read_text()
+        rel_path = str(doc_path.relative_to(ROOT))
+        issues.extend(scan_backticks(rel_path, content, valid, aliases))
+        issues.extend(scan_yaml_blocks(rel_path, content, valid, aliases))
+
+    if not issues:
+        print("No config key casing issues found.")
+        return 0
+
+    print(f"Issues: {len(issues)}")
+    for issue in issues:
+        print(
+            f"{issue.path}:{issue.line}: [{issue.kind}] "
+            f"`{issue.found}` should be `{issue.expected}`"
+        )
+
+    return 1
+
+
+if __name__ == "__main__":
+    sys.exit(main())

+ 3 - 1
docs/modules/ROOT/examples/reverse-proxies/etc/reverse_proxy_nginx_dns.conf

@@ -1,5 +1,8 @@
+# Requires Nginx 1.25.1+ with ngx_http_v2_module for "http2 on;".
+# On older Nginx, use "listen 443 ssl http2;" instead and omit the http2 directive.
 server {
 server {
     listen 443 ssl;
     listen 443 ssl;
+    http2 on;
 
 
     ssl_certificate "/etc/nginx/conf.d/server.crt";
     ssl_certificate "/etc/nginx/conf.d/server.crt";
     ssl_certificate_key "/etc/nginx/conf.d/server.key";
     ssl_certificate_key "/etc/nginx/conf.d/server.key";
@@ -22,4 +25,3 @@ server {
         proxy_send_timeout 600s;
         proxy_send_timeout 600s;
     }
     }
 }
 }
-

+ 5 - 6
docs/modules/ROOT/pages/action_examples/ssh-manual.adoc

@@ -3,7 +3,7 @@
 
 
 include::partial$action_examples/ssh_intro.adoc[]
 include::partial$action_examples/ssh_intro.adoc[]
 
 
-NOTE: There is an easy method of setting up SSH with OliveTin, which is described in the <<action-ssh-easy,SSH (easy setup)>> section. This section is for those who want to set up SSH manually.
+NOTE: There is an easy method of setting up SSH with OliveTin, which is described in the xref:action_examples/ssh-easy.adoc[SSH (easy setup)] section. This section is for those who want to set up SSH manually.
 
 
 :systemd: Easy
 :systemd: Easy
 :container: Needs some setting up - see the <<ssh-container,SSH Container setup instructions>>
 :container: Needs some setting up - see the <<ssh-container,SSH Container setup instructions>>
@@ -39,7 +39,7 @@ The steps in detail are below;
 [#ssh-step-1]
 [#ssh-step-1]
  [red]#Step 1#: Give OliveTin a SSH key
  [red]#Step 1#: Give OliveTin a SSH key
 
 
-Open a terminal window on _server-with-olivetin_. 
+Open a terminal window on _server-with-olivetin_.
 
 
 [loweralpha]
 [loweralpha]
 . Create the `/opt/OliveTinSshKeys` directory, to create a shared directory for your SSH key file.
 . Create the `/opt/OliveTinSshKeys` directory, to create a shared directory for your SSH key file.
@@ -49,7 +49,7 @@ Open a terminal window on _server-with-olivetin_.
 root@server-with-olivetin: mkdir /opt/OliveTinSshKeys
 root@server-with-olivetin: mkdir /opt/OliveTinSshKeys
 ----
 ----
 +
 +
-This will later be used as a "volume mount" when you create a docker container. 
+This will later be used as a "volume mount" when you create a docker container.
 
 
 . Run `ssh-keygen` to generate a SSH key just for OliveTin.
 . Run `ssh-keygen` to generate a SSH key just for OliveTin.
 +
 +
@@ -90,7 +90,7 @@ The key's randomart image is:
 +----[SHA256------+
 +----[SHA256------+
 ----
 ----
 +
 +
-This will create two files, `/opt/OliveTinSshKeys/id_rsa` (your private key) and `/opt/OliveTinSshKeys/id_rsa.pub` (your public key). 
+This will create two files, `/opt/OliveTinSshKeys/id_rsa` (your private key) and `/opt/OliveTinSshKeys/id_rsa.pub` (your public key).
 
 
 . Copy your public key to every server you want to connect to.
 . Copy your public key to every server you want to connect to.
 +
 +
@@ -113,7 +113,7 @@ root@server-with-olivetin: ssh -i /opt/OliveTinSshKeys/id_rsa root@server2
 (you should login without a password)
 (you should login without a password)
 ----
 ----
 
 
-. Give the SSH key to the OliveTin container. 
+. Give the SSH key to the OliveTin container.
 +
 +
 The way to do this is via a "volume mount". When you create the container, you use "-v" to specify a volume.
 The way to do this is via a "volume mount". When you create the container, you use "-v" to specify a volume.
 +
 +
@@ -156,4 +156,3 @@ actions:
      icon: ping
      icon: ping
      timeout: 5
      timeout: 5
 ....
 ....
-

+ 3 - 3
docs/modules/ROOT/pages/advanced_configuration/stylemods.adoc

@@ -4,9 +4,9 @@ There are several style modifications that some people like to use, which can ea
 
 
 [source,yaml]
 [source,yaml]
 ----
 ----
-stylemods:
+styleMods:
   - sm-side-icons
   - sm-side-icons
-----	
+----
 
 
 You can add as many style mods as you like, but note that some of them may conflict with each other. The style mods are applied in the order they are listed, so if you have a conflict, the last one in the list will take precedence.
 You can add as many style mods as you like, but note that some of them may conflict with each other. The style mods are applied in the order they are listed, so if you have a conflict, the last one in the list will take precedence.
 
 
@@ -19,7 +19,7 @@ You can add as many style mods as you like, but note that some of them may confl
 
 
 NOTE: The `sm-transparent-header` and `sm-transparent-footer` style mods often fix themes that were designed for OliveTin 2k.
 NOTE: The `sm-transparent-header` and `sm-transparent-footer` style mods often fix themes that were designed for OliveTin 2k.
 
 
-The list of style mods on this page is maintained as new style modifications are added to OliveTin. 
+The list of style mods on this page is maintained as new style modifications are added to OliveTin.
 
 
 == Feature history
 == Feature history
 
 

+ 2 - 2
docs/modules/ROOT/pages/api/method_StartActionByGet.adoc

@@ -1,6 +1,6 @@
 = API Method: StartActionByGet
 = API Method: StartActionByGet
 
 
-This is the method that allows you to specify the action ID in the URL, and is probably the best to do quick integrations - QR Codes, streamdeck, etc. You cannot pass arguments using this method. 
+This is the method that allows you to specify the action ID in the URL, and is probably the best to do quick integrations - QR Codes, streamdeck, etc. You cannot pass arguments using this method.
 
 
 * **HTTP Method**: `GET`
 * **HTTP Method**: `GET`
 * **Request Type**: Action ID in the URL
 * **Request Type**: Action ID in the URL
@@ -8,6 +8,7 @@ This is the method that allows you to specify the action ID in the URL, and is p
 
 
 include::partial$api/start_action_methods.adoc[]
 include::partial$api/start_action_methods.adoc[]
 
 
+[#api-eg-startIdUrl]
 == Example API call; Start an action by ID in the URL
 == Example API call; Start an action by ID in the URL
 
 
 .curl
 .curl
@@ -26,4 +27,3 @@ actions:
 ----
 ----
 
 
 IDs are used by these API calls, as you probably want the interface to display a human-readable title, whereas the API call doesn't want to have spaces or punctuation.
 IDs are used by these API calls, as you probably want the interface to display a human-readable title, whereas the API call doesn't want to have spaces or punctuation.
-

+ 1 - 3
docs/modules/ROOT/pages/api/start_action.adoc

@@ -13,7 +13,7 @@ Used by:
 *** xref:api/method_StartActionByGet.adoc[StartActionByGet]
 *** xref:api/method_StartActionByGet.adoc[StartActionByGet]
 *** xref:api/method_StartActionByGetAndWait.adoc[StartActionByGetAndWait]
 *** xref:api/method_StartActionByGetAndWait.adoc[StartActionByGetAndWait]
 
 
-If you are trying to integrate OliveTin with your own scripts or processes, it's probably easiest to start actions by using an ID directly in the URL, <<api-eg-startIdUrl,see the example>>.
+If you are trying to integrate OliveTin with your own scripts or processes, it's probably easiest to start actions by using an ID directly in the URL, xref:api/method_StartActionByGet.adoc#api-eg-startIdUrl[see the example].
 
 
 [#api-request-obj]
 [#api-request-obj]
 == Request type: OliveTin request object
 == Request type: OliveTin request object
@@ -101,5 +101,3 @@ Used by:
     }
     }
 }
 }
 ----
 ----
-
-

+ 31 - 30
docs/modules/ROOT/pages/config.adoc

@@ -8,6 +8,8 @@ file in the following locations;
 2. `/config/` - Mostly used for containers
 2. `/config/` - Mostly used for containers
 3. `/etc/OliveTin/` - this is the recommended directory on Linux for your `config.yaml`.
 3. `/etc/OliveTin/` - this is the recommended directory on Linux for your `config.yaml`.
 
 
+For a complete annotated example, see the https://github.com/OliveTin/OliveTin/blob/main/config.yaml[`config.yaml` in the OliveTin repository].
+
 The most simple `config.yaml` would be something like this;
 The most simple `config.yaml` would be something like this;
 
 
 .The most simple `config.yaml` file.
 .The most simple `config.yaml` file.
@@ -37,7 +39,7 @@ All configuration options are covered in the solution sections
 | Option | Description | Default | Live Reloadable | Documentation
 | Option | Description | Default | Live Reloadable | Documentation
 
 
 | `actions` | The list of available actions. | `-` | Live Reloadable, but refreshing the web browser is recommended. | xref:action_examples/intro.adoc[Action examples]
 | `actions` | The list of available actions. | `-` | Live Reloadable, but refreshing the web browser is recommended. | xref:action_examples/intro.adoc[Action examples]
-| `entities` | A list of "things" you can attach actions to. | `-` | Live Reloadable, but restart is recommended. | xref:entities/intro.adoc[Entities]
+| `entities` | A list of "things" you can attach actions to. | `-` | Entity data files and file path changes reload live; new entity definitions in config reload live. Restart if removing or renaming entity types (stale data may remain otherwise). | xref:entities/intro.adoc[Entities]
 | `dashboards` | A grouping of actions, with optional displays, or actions generated from entities. | `-` | Live Reloadable | xref:dashboards/intro.adoc[Dashboards]
 | `dashboards` | A grouping of actions, with optional displays, or actions generated from entities. | `-` | Live Reloadable | xref:dashboards/intro.adoc[Dashboards]
 |===
 |===
 
 
@@ -68,21 +70,21 @@ All configuration options are covered in the solution sections
 |===
 |===
 | Option | Description | Default | Live Reloadable | Documentation
 | Option | Description | Default | Live Reloadable | Documentation
 
 
-| `AuthJwtCookieName` | The name of the cookie to use for JWT authentication. | `` | Requires restart | xref:security/jwt_hmac.adoc[JWT with HMAC], xref:security/jwt_keys.adoc[JWT with Keys]
-| `AuthJwtAud` | The audience to use for JWT authentication. | `` | Requires restart | xref:security/jwt_keys.adoc[JWT with Keys]
-| `AuthJwtDomain` | The domain to use for JWT authentication. | `` | Requires restart | xref:security/jwt_hmac.adoc[JWT with HMAC], xref:security/jwt_keys.adoc[JWT with Keys]
-| `AuthJwtCertsURL` | The URL to fetch the public keys from with JWKS | `` | Requires restart | xref:security/jwt_keys.adoc[JWT with Keys]
-| `AuthJwtClaimUsername` | The claim to use for the username. | `sub` | Requires restart | xref:security/jwt_hmac.adoc[JWT with HMAC], xref:security/jwt_keys.adoc[JWT with Keys]
-| `AuthJwtClaimUserGroup` | The claim to use for the usergroup. | `sub` | Requires restart | xref:security/jwt_hmac.adoc[JWT with HMAC], xref:security/jwt_keys.adoc[JWT with Keys]
-| `AuthJwtHeader` | The HTTP header to use for JWT authentication. | `` | Requires restart | xref:security/jwt_keys.adoc[JWT with Keys]
-| `AuthJwtPubKeyPath` | The path to the public key to use for JWT authentication. | `` | Requires restart | xref:security/jwt_keys.adoc[JWT with Keys]
-| `AuthHttpHeaderUsername` | The HTTP header to use for the username. | `` | Requires restart | xref:security/trusted_header.adoc[Trusted Headers]
-| `AuthHttpHeaderUserGroup` | The HTTP header to use for the usergroup. | `` | Requires restart | xref:security/trusted_header.adoc[Trusted Headers]
-| `AuthLocalUsers` | The list of local users. | `[]` | Requires restart | xref:security/local.adoc[Local Users]
-| `AuthLoginUrl` | The URL to redirect to for login. | `` | Requires restart | xref:security/local.adoc[Login URL]
-| `AuthRequireGuestsToLogin` | Basically disables all functionality for guests. It sets all default permissions to false. | `false` | Requires restart | xref:security/acl.adoc[Access Control Lists]
-| `DefaultPermissions` | The default permissions to use. | `[]` | Requires restart | xref:security/acl.adoc[Access Control Lists]
-| `AccessControlLists` | The list of access control lists. | `[]` | Requires restart | xref:security/acl.adoc[Access Control Lists]
+| `authJwtCookieName` | The name of the cookie to use for JWT authentication. | `` | Requires restart | xref:security/jwt_hmac.adoc[JWT with HMAC], xref:security/jwt_keys.adoc[JWT with Keys]
+| `authJwtAud` | The audience to use for JWT authentication. | `` | Requires restart | xref:security/jwt_keys.adoc[JWT with Keys]
+| `authJwtDomain` | The domain to use for JWT authentication. | `` | Requires restart | xref:security/jwt_hmac.adoc[JWT with HMAC], xref:security/jwt_keys.adoc[JWT with Keys]
+| `authJwtCertsUrl` | The URL to fetch the public keys from with JWKS | `` | Requires restart | xref:security/jwt_keys.adoc[JWT with Keys]
+| `authJwtClaimUsername` | The claim to use for the username. | `sub` | Requires restart | xref:security/jwt_hmac.adoc[JWT with HMAC], xref:security/jwt_keys.adoc[JWT with Keys]
+| `authJwtClaimUserGroup` | The claim to use for the usergroup. | `sub` | Requires restart | xref:security/jwt_hmac.adoc[JWT with HMAC], xref:security/jwt_keys.adoc[JWT with Keys]
+| `authJwtHeader` | The HTTP header to use for JWT authentication. | `` | Requires restart | xref:security/jwt_keys.adoc[JWT with Keys]
+| `authJwtPubKeyPath` | The path to the public key to use for JWT authentication. | `` | Requires restart | xref:security/jwt_keys.adoc[JWT with Keys]
+| `authHttpHeaderUsername` | The HTTP header to use for the username. | `` | Requires restart | xref:security/trusted_header.adoc[Trusted Headers]
+| `authHttpHeaderUserGroup` | The HTTP header to use for the usergroup. | `` | Requires restart | xref:security/trusted_header.adoc[Trusted Headers]
+| `authLocalUsers` | The list of local users. | `[]` | Requires restart | xref:security/local.adoc[Local Users]
+| `authLoginUrl` | The URL to redirect to for login. | `` | Requires restart | xref:security/local.adoc[Login URL]
+| `authRequireGuestsToLogin` | Basically disables all functionality for guests. It sets all default permissions to false. | `false` | Requires restart | xref:security/acl.adoc[Access Control Lists]
+| `defaultPermissions` | The default permissions to use. | `[]` | Requires restart | xref:security/acl.adoc[Access Control Lists]
+| `accessControlLists` | The list of access control lists. | `[]` | Requires restart | xref:security/acl.adoc[Access Control Lists]
 | `security.headerContentSecurityPolicy` | Whether to send a `Content-Security-Policy` header from the single HTTP frontend. | `true` | Live reloadable | xref:security/content_security_policy.adoc[Content Security Policy headers]
 | `security.headerContentSecurityPolicy` | Whether to send a `Content-Security-Policy` header from the single HTTP frontend. | `true` | Live reloadable | xref:security/content_security_policy.adoc[Content Security Policy headers]
 | `security.contentSecurityPolicy` | CSP header value when `security.headerContentSecurityPolicy` is enabled. If empty, a built-in default is used. | (built-in default) | Live reloadable | xref:security/content_security_policy.adoc[Content Security Policy headers]
 | `security.contentSecurityPolicy` | CSP header value when `security.headerContentSecurityPolicy` is enabled. If empty, a built-in default is used. | (built-in default) | Live reloadable | xref:security/content_security_policy.adoc[Content Security Policy headers]
 |===
 |===
@@ -92,13 +94,12 @@ All configuration options are covered in the solution sections
 |===
 |===
 | Option | Description | Default | Live Reloadable | Documentation
 | Option | Description | Default | Live Reloadable | Documentation
 
 
-| `UseSingleHttpFrontend` | Whether or not to start the internal "microproxy" frontend. Disabling this is highly unusual and is only really useful for power users.  | true | Requires Restart | xref:reference/network-ports.adoc[Network Ports]
-| `ListenAddressSingleHTTPFrontend` | The address to listen on for the internal "microproxy" frontend. | `0.0.0.0:1337` | Requires Restart | xref:reference/network-ports.adoc[Network Ports]
-| `ListenAddressWebUI` | The address to listen on for the web UI. | `localhost:1340` | Requires Restart | xref:reference/network-ports.adoc[Network Ports]
-| `ListenAddressRestActions` | The address for the API | `localhost:1338` | Requires Restart | xref:reference/network-ports.adoc[Network Ports]
-| `ListenAddressGrpcActions` | The address for the gRPC API | `localhost:1339` | Requires Restart | xref:reference/network-ports.adoc[Network Ports]
-| `ListenAddressPrometheus` | The address for the Prometheus metrics | `localhost:1341` | Requires Restart | xref:reference/network-ports.adoc[Network Ports], xref:advanced_configuration/prometheus.adoc[Prometheus]
-| `ExternalRestAddress` | The address the web browser should use to connect to the API. | `.` | Requires Restart | xref:reference/network-ports.adoc[Network Ports]
+| `useSingleHTTPFrontend` | Whether or not to start the internal "microproxy" frontend. Disabling this is highly unusual and is only really useful for power users.  | true | Requires Restart | xref:reference/network-ports.adoc[Network Ports]
+| `listenAddressSingleHTTPFrontend` | The address to listen on for the internal "microproxy" frontend. | `0.0.0.0:1337` | Requires Restart | xref:reference/network-ports.adoc[Network Ports]
+| `listenAddressWebUI` | The address to listen on for the web UI. | `localhost:1340` | Requires Restart | xref:reference/network-ports.adoc[Network Ports]
+| `listenAddressRestActions` | The address for the API | `localhost:1338` | Requires Restart | xref:reference/network-ports.adoc[Network Ports]
+| `listenAddressPrometheus` | The address for the Prometheus metrics | `localhost:1341` | Requires Restart | xref:reference/network-ports.adoc[Network Ports], xref:advanced_configuration/prometheus.adoc[Prometheus]
+| `externalRestAddress` | The address the web browser should use to connect to the API. | `.` | Requires Restart | xref:reference/network-ports.adoc[Network Ports]
 |===
 |===
 
 
 == Debugging Configuration
 == Debugging Configuration
@@ -106,8 +107,8 @@ All configuration options are covered in the solution sections
 |===
 |===
 | Option | Description | Default | Live Reloadable | Documentation
 | Option | Description | Default | Live Reloadable | Documentation
 
 
-| `LogLevel` | The log level to use. `INFO`, `DEBUG`, `WARN` | `INFO` | Requires Restart | -
-| `LogDebugOptions` | Enable various debug logs. | `-` | Requires Restart | xref:troubleshooting/advanced.adoc[Advanced Troubleshooting]
+| `logLevel` | The log level to use. `INFO`, `DEBUG`, `WARN` | `INFO` | Requires Restart | -
+| `logDebugOptions` | Enable various debug logs. | `-` | Requires Restart | xref:troubleshooting/advanced.adoc[Advanced Troubleshooting]
 | `Insecure*` | Various options to disable security features. | `false` | Restart recommended | xref:troubleshooting/advanced.adoc[Advanced Troubleshooting]
 | `Insecure*` | Various options to disable security features. | `false` | Restart recommended | xref:troubleshooting/advanced.adoc[Advanced Troubleshooting]
 |===
 |===
 
 
@@ -116,11 +117,11 @@ All configuration options are covered in the solution sections
 |===
 |===
 | Option | Description | Default | Live Reloadable | Documentation
 | Option | Description | Default | Live Reloadable | Documentation
 
 
-| `WebUIDir` | The directory to serve the web UI from. | Calculated at runtime. | Requires Restart | -
-| `CronSupportForSeconds` | Whether or not to support seconds in cron expressions. | `false` | Requires Restart | xref:action_execution/oncron.adoc[Cron]
-| `SaveLogs` | Whether or not to save logs to disk. | `[]` | Requires Restart | xref:logs/saving.adoc[Save Logs]
-| `ServiceLogs` | Windows process log directory (`serviceLogs.directory`). | `%ProgramData%\OliveTin\logs\` on Windows | Requires Restart | xref:install/windows_service.adoc#windows-service-logs[Windows service logs]
-| `Prometheus` | Prometheus configuration. | `-` | Requires Restart | xref:advanced_configuration/prometheus.adoc[Prometheus]
+| `webUIDir` | The directory to serve the web UI from. | Calculated at runtime. | Requires Restart | -
+| `cronSupportForSeconds` | Whether or not to support seconds in cron expressions. | `false` | Requires Restart | xref:action_execution/oncron.adoc[Cron]
+| `saveLogs` | Whether or not to save logs to disk. | `[]` | Requires Restart | xref:logs/saving.adoc[Save Logs]
+| `serviceLogs` | Windows process log directory (`serviceLogs.directory`). | `%ProgramData%\OliveTin\logs\` on Windows | Requires Restart | xref:install/windows_service.adoc#windows-service-logs[Windows service logs]
+| `prometheus` | Prometheus configuration. | `-` | Requires Restart | xref:advanced_configuration/prometheus.adoc[Prometheus]
 |===
 |===
 
 
 == What's Next?
 == What's Next?

+ 25 - 1
docs/modules/ROOT/pages/entities/intro.adoc

@@ -7,7 +7,31 @@ This is really useful if you want to generate wake on lan or poweroff actions fo
 
 
 A very popular use case that entities were designed for was for `container` entities - in a similar way you could generate `start`, `stop`, and `restart` container actions.
 A very popular use case that entities were designed for was for `container` entities - in a similar way you could generate `start`, `stop`, and `restart` container actions.
 
 
-Entities are just loaded from files on disk, OliveTin will also watch these files for updates while OliveTin is running, and update entities.
+Entities are just loaded from files on disk. OliveTin watches these files for updates while it is running and refreshes dashboards, actions, and the Entities page when data changes.
+
+[#display-names]
+=== Display names
+
+Each entity instance needs a human-readable name in the UI. OliveTin picks the first matching string field from this list (case-insensitive field names):
+
+`title`, `name`, `id`, `hostname`, `host`, `label`
+
+If none of those fields contain a string value, the instance appears as **Untitled Entity**. Add one of those fields to every row in your entity file — for example `title: My Vehicle` or `name: server1`.
+
+The chosen field is shown as the instance name in lists and dashboards. On the entity details page, it appears in the page heading; see xref:entities/properties.adoc#entity-details-page[Entity details page] for which other fields are listed.
+
+[#entity-live-reload]
+=== Live reload
+
+* **Entity file content** — when a watched data file is updated on disk, OliveTin reloads instances and notifies the web UI. No restart is required.
+* **New entity types in `config.yaml`** — when you add a new entry under `entities:` and reload config, OliveTin starts watching that file without a full restart.
+* **Entity file path changes** — when you change an entity's file path in config and reload, OliveTin switches to the new file without a restart.
+* **Removing or renaming entity types** — restart OliveTin if you remove an entity definition or rename its type; otherwise stale entity data may remain in memory until restart.
+
+[NOTE]
+====
+On **Docker Desktop for Windows**, bind-mounted entity files may not trigger file-watch events when updated from the host (for example by a cron job writing through a volume). If live reload does not pick up changes, restart the container after updating the file, run OliveTin on Linux, or write the file in place on the same filesystem OliveTin watches.
+====
 
 
 Entity data files can contain any fields you need. Those values are available in action templates as `{{ .CurrentEntity.field }}` — for example, `{{ .CurrentEntity.status }}` or `{{ .CurrentEntity.hostname }}`.
 Entity data files can contain any fields you need. Those values are available in action templates as `{{ .CurrentEntity.field }}` — for example, `{{ .CurrentEntity.status }}` or `{{ .CurrentEntity.hostname }}`.
 
 

+ 14 - 2
docs/modules/ROOT/pages/entities/json.adoc

@@ -3,10 +3,22 @@
 
 
 JSON files are parsed as if each line is a single JSON object. This can be super helpful for getting a list of containers, for example; `docker ps -a --format=json > /etc/OliveTin/containers.json`.
 JSON files are parsed as if each line is a single JSON object. This can be super helpful for getting a list of containers, for example; `docker ps -a --format=json > /etc/OliveTin/containers.json`.
 
 
+== Display names in JSON
+
+OliveTin uses the first string value found in fields named `title`, `name`, `id`, `hostname`, `host`, or `label` as the instance name in the UI. Matching is case-insensitive. See xref:entities/intro.adoc#display-names[Display names] in the entities overview.
+
+Docker's JSON output uses `Names`, which is **not** one of those fields. Without `title` or `name`, OliveTin falls back to Docker's `ID` field (matched as `id`) and shows the container ID instead of the human-readable name. Either map `Names` when generating the file (for example with `jq` to add a `name` field) or include an explicit `title` or `name` key in each object.
+
+[source,json]
+.Example with an explicit display name
+----
+{"title":"media-indexing-container","ID":"4bafe6f9f956","Image":"fedora","State":"exited"}
+{"title":"media-playback-container","ID":"d25f37c49c35","Image":"fedora","State":"exited"}
+----
+
 [source,json]
 [source,json]
-.`/etc/OliveTin/containers.json`
+.Raw `docker ps --format=json` lines (without `title` or `name`, OliveTin shows `ID` instead of `Names`)
 ----
 ----
 {"Command":"\"/opt/entrypoint.sh\"","CreatedAt":"2024-02-08 15:27:42 +0000 GMT","ID":"4bafe6f9f956","Image":"fedora","Labels":"?","LocalVolumes":"0","Mounts":"","Names":"media-indexing-container","Networks":"bridge","Ports":"","RunningFor":"13 days ago","Size":"0B","State":"exited","Status":"Exited (128) 13 days ago"}
 {"Command":"\"/opt/entrypoint.sh\"","CreatedAt":"2024-02-08 15:27:42 +0000 GMT","ID":"4bafe6f9f956","Image":"fedora","Labels":"?","LocalVolumes":"0","Mounts":"","Names":"media-indexing-container","Networks":"bridge","Ports":"","RunningFor":"13 days ago","Size":"0B","State":"exited","Status":"Exited (128) 13 days ago"}
 {"Command":"\"/opt/entrypoint.sh\"","CreatedAt":"2023-12-17 20:58:03 +0000 GMT","ID":"d25f37c49c35","Image":"fedora","Labels":"?","LocalVolumes":"0","Mounts":"","Names":"media-playback-container","Networks":"bridge","Ports":"","RunningFor":"27 days ago","Size":"0B","State":"exited","Status":"Exited (137) 27 days ago"}
 {"Command":"\"/opt/entrypoint.sh\"","CreatedAt":"2023-12-17 20:58:03 +0000 GMT","ID":"d25f37c49c35","Image":"fedora","Labels":"?","LocalVolumes":"0","Mounts":"","Names":"media-playback-container","Networks":"bridge","Ports":"","RunningFor":"27 days ago","Size":"0B","State":"exited","Status":"Exited (137) 27 days ago"}
 ----
 ----
-

+ 2 - 1
docs/modules/ROOT/pages/entities/properties.adoc

@@ -61,9 +61,10 @@ When `properties` is configured, the Entities page shows a searchable, paginated
 
 
 When `properties` is omitted or empty, the Entities page shows a simple list of instance names and a total count instead of a table.
 When `properties` is omitted or empty, the Entities page shows a simple list of instance names and a total count instead of a table.
 
 
+[#entity-details-page]
 === Entity details page
 === Entity details page
 
 
-The entity details view shows only the fields listed in `properties`, plus type and title.
+The entity details view shows only the fields listed in `properties`, plus type. The instance name appears in the page title, not again in the field list.
 
 
 If `properties` is not configured, all top-level fields from the entity data file are shown.
 If `properties` is not configured, all top-level fields from the entity data file are shown.
 
 

+ 3 - 2
docs/modules/ROOT/pages/entities/yaml.adoc

@@ -1,7 +1,9 @@
 [#entities-yaml]
 [#entities-yaml]
 = YAML entity files
 = YAML entity files
 
 
-YAML files are the default expected format, so you can use .yml, .yaml, or even .txt - as long as the file contains a valid yaml LIST, then it will be loaded. 
+YAML files are the default expected format, so you can use .yml, .yaml, or even .txt - as long as the file contains a valid yaml LIST, then it will be loaded.
+
+Each list item should include a display name field (`title`, `name`, `id`, `hostname`, `host`, or `label`). The example below uses `name`. See xref:entities/intro.adoc#display-names[Display names].
 
 
 .`/etc/OliveTin/servers.yaml`
 .`/etc/OliveTin/servers.yaml`
 [source,yaml]
 [source,yaml]
@@ -19,4 +21,3 @@ YAML files are the default expected format, so you can use .yml, .yaml, or even
   hostname: server3.example.com
   hostname: server3.example.com
   ip: 192.168.0.3
   ip: 192.168.0.3
 ----
 ----
-

+ 2 - 0
docs/modules/ROOT/pages/logs/saving.adoc

@@ -3,6 +3,8 @@
 
 
 By default, OliveTin only keeps logs in memory, meaning that if you restart OliveTin your logs will be lost. For some use cases this is acceptable, but you can configure OliveTin to save logs for you.
 By default, OliveTin only keeps logs in memory, meaning that if you restart OliveTin your logs will be lost. For some use cases this is acceptable, but you can configure OliveTin to save logs for you.
 
 
+When `saveLogs` is enabled, OliveTin writes a file for each configured directory when an execution finishes — a results YAML when `resultsDirectory` is set, and an output log when `outputDirectory` is set. If only one directory is configured, only that file type is written. This includes blocked runs (for example when concurrency or rate limits prevent the action from starting).
+
 You can configure the global setting for saving logs, or override it on a per-action basis;
 You can configure the global setting for saving logs, or override it on a per-action basis;
 
 
 [source,yaml]
 [source,yaml]

+ 3 - 3
docs/modules/ROOT/pages/reference/release_policy.adoc

@@ -6,11 +6,11 @@ This page is a collection of notes around the policy for releases.
 
 
 The OliveTin project *WILL* delete packages and tags in the following situations;
 The OliveTin project *WILL* delete packages and tags in the following situations;
 
 
-* A release went out accidently, or the package is broken in a way that prevents installation or use (something critical).
+* A release went out accidentally, or the package is broken in a way that prevents installation or use (something critical).
 * A release went out with the wrong tag, or wrong tag format (eg 2025-11-06 instead of 2025.11.06) for OliveTin 2k.
 * A release went out with the wrong tag, or wrong tag format (eg 2025-11-06 instead of 2025.11.06) for OliveTin 2k.
-  * This is because registries that contain bag tag formats that do not match smever will cause issues for users trying to install or upgrade in the future.
+  * This is because registries that contain bad tag formats that do not match semver will cause issues for users trying to install or upgrade in the future.
 
 
-It is understood that this can break some users workflows, but deleting packages occours rarely, and is only done to prevent further issues.
+It is understood that this can break some users' workflows, but deleting packages occurs rarely, and is only done to prevent further issues.
 
 
 If this is really a concern that you want to guard against, it is recommended to run your own private registry/mirror of OliveTin packages.
 If this is really a concern that you want to guard against, it is recommended to run your own private registry/mirror of OliveTin packages.
 
 

+ 2 - 1
docs/modules/ROOT/pages/reverse-proxies/nginx.adoc

@@ -5,6 +5,8 @@ include::partial$reverse-proxies/diagram.adoc[]
 
 
 This is an example of DNS based proxying with Nginx.
 This is an example of DNS based proxying with Nginx.
 
 
+The example uses `http2 on;`, which requires Nginx 1.25.1 or later with `ngx_http_v2_module`. On older Nginx versions, replace it with `listen 443 ssl http2;` and remove the separate `http2` directive.
+
 ./etc/nginx/cond.d/OliveTin.conf
 ./etc/nginx/cond.d/OliveTin.conf
 [source,nginx]
 [source,nginx]
 ----
 ----
@@ -39,4 +41,3 @@ These "custom path" instructions are for when you want to use OliveTin with a cu
 ----
 ----
 
 
 include::partial$reverse-proxies/external-rest.adoc[]
 include::partial$reverse-proxies/external-rest.adoc[]
-

+ 3 - 3
docs/modules/ROOT/pages/security/example_login_required.adoc

@@ -40,11 +40,11 @@ dashboards:
 
 
 Note, to use this configuration, you will need to replace `-- your password hash here --` with a password hash. You can generate a password hash by looking at the options in the xref:security/local.adoc[local-users] configuration section.
 Note, to use this configuration, you will need to replace `-- your password hash here --` with a password hash. You can generate a password hash by looking at the options in the xref:security/local.adoc[local-users] configuration section.
 
 
-== Important configuration option: `AuthRequireGuestsToLogin`
+== Important configuration option: `authRequireGuestsToLogin`
 
 
-The `AuthRequireGuestsToLogin` option is a helpful shortcut that sets all `defaultPermissions` to false, and makes it so that all guests are prompted to login before they can do anything with OliveTin.
+The `authRequireGuestsToLogin` option is a helpful shortcut that sets all `defaultPermissions` to false, and makes it so that all guests are prompted to login before they can do anything with OliveTin.
 
 
-Technically, you could achieve the same effect by setting `defaultPermissions` to `false` and setting up an ACL that allows access to the login page, but `AuthRequireGuestsToLogin` is a more convenient way to achieve the same effect.
+Technically, you could achieve the same effect by setting `defaultPermissions` to `false` and setting up an ACL that allows access to the login page, but `authRequireGuestsToLogin` is a more convenient way to achieve the same effect.
 
 
 == Per-action ACLs, vs `addToEveryAction`
 == Per-action ACLs, vs `addToEveryAction`
 
 

+ 3 - 3
docs/modules/ROOT/pages/security/oauth2.adoc

@@ -27,7 +27,7 @@ authOAuth2Providers:
 * `tokenUrl` - the URL to exchange the code for a token
 * `tokenUrl` - the URL to exchange the code for a token
 * `whoamiUrl` - the URL to fetch user information from
 * `whoamiUrl` - the URL to fetch user information from
 * `usernameField` - the field in the user information response to use as the username
 * `usernameField` - the field in the user information response to use as the username
-* `userGroupField` - the field in the user information response to use as the group. This is a string containing one group name, e.g. `olivetin_group`
+* `userGroupField` - the field in the user information response to use as the group. This can either be a string containing one group name (e.g. `olivetin_group`), or an array of group names (e.g. a `groups` claim returned by many OIDC providers). When multiple groups are found, they are joined into one usergroup value using `authHttpHeaderUserGroupSep` (space-separated by default) - see xref:security/acl.adoc[Access Control Lists] for how this is matched against ACLs.
 * `addToUsergroup` - a group name to add to every user who logs in via this provider. If the user already has a usergroup (e.g. from `userGroupField`), this value is appended to it; otherwise it becomes the user's usergroup. Useful for giving all users from this provider a common group for ACLs, e.g. `addToUsergroup: github`
 * `addToUsergroup` - a group name to add to every user who logs in via this provider. If the user already has a usergroup (e.g. from `userGroupField`), this value is appended to it; otherwise it becomes the user's usergroup. Useful for giving all users from this provider a common group for ACLs, e.g. `addToUsergroup: github`
 * `certBundlePath` - the path to a certificate to add to the truststore for authentication requests, e.g. `/certs/internal.crt`
 * `certBundlePath` - the path to a certificate to add to the truststore for authentication requests, e.g. `/certs/internal.crt`
 * `insecureSkipVerify` - a boolean to disable certificate verfication
 * `insecureSkipVerify` - a boolean to disable certificate verfication
@@ -70,7 +70,7 @@ authOAuth2Providers:
     addToUsergroup: github
     addToUsergroup: github
 ```
 ```
 
 
-Then in your actions you can restrict access with `allowedUserGroups: ["github"]`.
+Then in your ACLs you can restrict access with `matchUsergroups: ["github"]`.
 
 
 === Combining with userGroupField
 === Combining with userGroupField
 
 
@@ -103,4 +103,4 @@ authOAuth2Providers:
     addToUsergroup: google
     addToUsergroup: google
 ```
 ```
 
 
-Then use ACLs such as `allowedUserGroups: ["github"]` for GitHub-only actions or `allowedUserGroups: ["github", "google"]` for any OAuth user.
+Then use ACLs such as `matchUsergroups: ["github"]` for GitHub-only actions or `matchUsergroups: ["github", "google"]` for any OAuth user.

+ 61 - 6
docs/modules/ROOT/pages/security/oauth2_authelia.adoc

@@ -1,6 +1,6 @@
 = OAuth2 - Authelia
 = OAuth2 - Authelia
 
 
-Notes contributed by a member of the OliveTin community - many thanks Phampyk! 
+Notes contributed by a member of the OliveTin community - many thanks Phampyk!
 
 
 [source,yaml]
 [source,yaml]
 .Authelia code
 .Authelia code
@@ -14,7 +14,7 @@ identity_providers:
         algorithm: "RS256"
         algorithm: "RS256"
         use: "sig"
         use: "sig"
         key: |
         key: |
-          -----BEGIN PRIVATE KEY-----
+          -----BEGIN PRIVATE KEY----- #notsecret
           xxxxxxxxxxxxxxxxxxxxxxxxxx
           xxxxxxxxxxxxxxxxxxxxxxxxxx
           -----END PRIVATE KEY-----
           -----END PRIVATE KEY-----
 
 
@@ -46,9 +46,9 @@ Digest: $pbkdf2-sha512$310000$yQogpMZvkHoAmOBGiIHVJQ$hxKuvar6Q6pOlkdzQBMWq1i5WjX
 [source,yaml]
 [source,yaml]
 ----
 ----
 authRequireGuestsToLogin: true
 authRequireGuestsToLogin: true
-authOAuth2RedirectURL: https://olivetin.hostname.com/oauth/callback
+authOAuth2RedirectUrl: https://olivetin.hostname.com/oauth/callback
 authOAuth2Providers:
 authOAuth2Providers:
-  authelia: 
+  authelia:
     name: authelia
     name: authelia
     title: Authelia
     title: Authelia
     clientID: olivetin  #same as authelia
     clientID: olivetin  #same as authelia
@@ -61,7 +61,7 @@ authOAuth2Providers:
       - profile
       - profile
     usernameField: preferred_username
     usernameField: preferred_username
     icon: <iconify-icon icon="simple-icons:authelia"></iconify-icon>
     icon: <iconify-icon icon="simple-icons:authelia"></iconify-icon>
-	
+
 accessControlLists:
 accessControlLists:
   - name: john #same as authelia
   - name: john #same as authelia
     matchUserNames:
     matchUserNames:
@@ -73,7 +73,62 @@ accessControlLists:
     addToEveryAction: true
     addToEveryAction: true
 ----
 ----
 
 
+== Group-based access control
+
+Rather than (or as well as) matching on individual usernames, you can grant access based on Authelia group membership. Authelia's OIDC provider can return a user's groups as a `groups` claim, which is an array (e.g. `["admins", "operators"]`), not a single string.
+
+First, request the `groups` scope for the client in Authelia:
+
+[source,yaml]
+.Authelia code
+----
+identity_providers:
+  oidc:
+    clients:
+      - client_id: "olivetin"
+        # ... other settings as above ...
+        scopes:
+          - openid
+          - profile
+          - groups
+----
+
+Then tell OliveTin which field to read the groups from with `userGroupField`. OliveTin accepts the `groups` claim as a native array - no need to flatten it into a string yourself:
+
+[source,yaml]
+.OliveTin config
+----
+authOAuth2Providers:
+  authelia:
+    name: authelia
+    title: Authelia
+    clientID: olivetin
+    clientSecret: xxxxxxx
+    authURL: https://authelia.hostname.com/api/oidc/authorization
+    tokenURL: https://authelia.hostname.com/api/oidc/token
+    whoamiUrl: https://authelia.hostname.com/api/oidc/userinfo
+    scopes:
+      - openid
+      - profile
+      - groups
+    usernameField: preferred_username
+    userGroupField: groups
+
+accessControlLists:
+  - name: admins
+    matchUsergroups:
+      - admins
+    permissions:
+      view: true
+      exec: true
+      logs: true
+    addToEveryAction: true
+----
+
+A user who is a member of multiple Authelia groups (e.g. `admins` and `operators`) will match any ACL whose `matchUsergroups` contains one of those groups - OliveTin combines the permissions from every matching ACL.
+
+If you need the joined groups to use a specific separator (e.g. because a group name itself contains a space), set `authHttpHeaderUserGroupSep`, e.g. `authHttpHeaderUserGroupSep: ","`. This is optional; the default separator is a space.
+
 == Next steps
 == Next steps
 
 
 Once you have OAuth2 working, you will probably want to configure access control lists in OliveTin. This is described in the xref:security/acl.adoc[Access Control Lists] documentation page.
 Once you have OAuth2 working, you will probably want to configure access control lists in OliveTin. This is described in the xref:security/acl.adoc[Access Control Lists] documentation page.
-

+ 6 - 4
docs/modules/ROOT/pages/security/oauth2_authentik.adoc

@@ -37,6 +37,9 @@ OliveTin `2024.11.24` added support for OAuth2 group mapping for a single group.
 
 
 The examples below show various ways to map groups from Authentik to OliveTin.
 The examples below show various ways to map groups from Authentik to OliveTin.
 
 
+[NOTE]
+OliveTin can also accept groups returned as a native list/array, instead of a comma-separated string. If your scope mapping expression returns `{"olivetin_group_list": groups}` (a plain list) rather than joining it with `",".join(groups)`, OliveTin will read it the same way. The comma-separated approach below still works and is kept for reference.
+
 === Multiple group mapping: Comma-separated list
 === Multiple group mapping: Comma-separated list
 
 
 The below will match all groups the user is a member of and return them as a comma-separated list. If no groups are found then an empty string is returned (no groups)".
 The below will match all groups the user is a member of and return them as a comma-separated list. If no groups are found then an empty string is returned (no groups)".
@@ -89,7 +92,7 @@ return {
 ----
 ----
 
 
 [IMPORTANT]
 [IMPORTANT]
-If you use this multiple group mapping, you will need to set the `AuthHttpHeaderUserGroupSep` field to `,`. This may sound like a strangely named field, but it is the correct one to use for this mapping. It was originally created for the HTTP Trusted Header authentication method, but it is also used for OAuth2 group mapping.
+If you use this multiple group mapping, you will need to set the `authHttpHeaderUserGroupSep` field to `,`. This may sound like a strangely named field, but it is the correct one to use for this mapping. It was originally created for the HTTP Trusted Header authentication method, but it is also used for OAuth2 group mapping.
 
 
 === Single group mapping: Specific Group Match
 === Single group mapping: Specific Group Match
 
 
@@ -183,9 +186,9 @@ image::authentik_login3.png[]
 
 
 == Debugging
 == Debugging
 
 
-OliveTin logs OAuth2 flows quite extensively. If you are having trouble with OAuth2, you should check your OliveTin logs. 
+OliveTin logs OAuth2 flows quite extensively. If you are having trouble with OAuth2, you should check your OliveTin logs.
 
 
-You may see errors such as "OAuth2: Error getting user data" or "Failed to get field from user data". 
+You may see errors such as "OAuth2: Error getting user data" or "Failed to get field from user data".
 
 
 Sometimes it can be infuriating to debug the user data mapping (username and usergroup), as you cannot easily capture the data that is being sent back from Authentik. To help with this, you can temporarily enable a debug log flag that is INSECURE (do not leave this enabled) to log the user data that is being sent back from Authentik. To do this, add the following to your OliveTin configuration file:
 Sometimes it can be infuriating to debug the user data mapping (username and usergroup), as you cannot easily capture the data that is being sent back from Authentik. To help with this, you can temporarily enable a debug log flag that is INSECURE (do not leave this enabled) to log the user data that is being sent back from Authentik. To do this, add the following to your OliveTin configuration file:
 
 
@@ -200,4 +203,3 @@ Once you have this working, you can disable the `insecureAllowDumpOAuth2UserData
 == Next steps
 == Next steps
 
 
 Once you have OAuth2 working, you will probably want to configure access control lists in OliveTin. This is described in the xref:security/acl.adoc[Access Control Lists] documentation page.
 Once you have OAuth2 working, you will probably want to configure access control lists in OliveTin. This is described in the xref:security/acl.adoc[Access Control Lists] documentation page.
-

+ 4 - 4
docs/modules/ROOT/pages/security/trusted_header.adoc

@@ -17,18 +17,18 @@ To configure Trusted Header Authorization, set the following configuration optio
 .`config.yaml`
 .`config.yaml`
 ----
 ----
 authHttpHeaderUsername: "X-Username"
 authHttpHeaderUsername: "X-Username"
-authHttpHeaderUsergroup: "X-Usergroup"
+authHttpHeaderUserGroup: "X-Usergroup"
 ----
 ----
 
 
 The value of `X-Username` and `X-Usergroup` can be whatever you like, as long as they match the headers set by your reverse proxy.
 The value of `X-Username` and `X-Usergroup` can be whatever you like, as long as they match the headers set by your reverse proxy.
 
 
-NOTE: You *must* set `AuthHttpHeaderUsername` to some value, even if you only intend to use `AuthHttpHeaderUsergroup`, otherwise usergroups will be ignored.
+NOTE: You *must* set `authHttpHeaderUsername` to some value, even if you only intend to use `authHttpHeaderUserGroup`, otherwise usergroups will be ignored.
 
 
 == Multiple usergroups
 == Multiple usergroups
 
 
-OliveTin will automatically detect multiple usergroups in the `authHttpHeaderUsergroup` header if they are separated by a space. You can also set a configuration option to use a different separator string with `authHttpHeaderUsergroupSep`. For example, if you set `authHttpHeaderUsergroupSep` to `,`, then the header `X-Usergroup: group1,group2` will be interpreted as two usergroups: `group1` and `group2`. 
+OliveTin will automatically detect multiple usergroups in the `authHttpHeaderUserGroup` header if they are separated by a space. You can also set a configuration option to use a different separator string with `authHttpHeaderUserGroupSep`. For example, if you set `authHttpHeaderUserGroupSep` to `,`, then the header `X-Usergroup: group1,group2` will be interpreted as two usergroups: `group1` and `group2`.
 
 
 [source, yaml]
 [source, yaml]
 ----
 ----
-authHttpHeaderUsergroupSep: ","
+authHttpHeaderUserGroupSep: ","
 ----
 ----

+ 1 - 2
docs/modules/ROOT/partials/action_execution/onfileindir_arguments.adoc

@@ -13,5 +13,4 @@
 | `fileisdir`           | false
 | `fileisdir`           | false
 |===
 |===
 
 
-Like all arguments, OliveTin also passes these arguments as <<env-vars,environment variables>> if this is better for your use case.
-
+Like all arguments, OliveTin also passes these arguments as xref:args/env.adoc[environment variables] if this is better for your use case.

+ 1 - 3
docs/modules/ROOT/partials/container_socket.adoc

@@ -69,6 +69,4 @@ xref:reference/containerInstallPackages.adoc[How to install additional packages
 +
 +
 NOTE: The reason that the `arm` and `arm64` containers do not include docker, is that when these images are cross-compiled at build time, it takes FOREVER because we have to emulate arm.
 NOTE: The reason that the `arm` and `arm64` containers do not include docker, is that when these images are cross-compiled at build time, it takes FOREVER because we have to emulate arm.
 
 
-After you have passed the socket into the container (and optionally installed docker), you should be able to setup docker actions like it's shown in the example <<example-control-containers,above>>.
-
-
+After you have passed the socket into the container (and optionally installed docker), you should be able to setup docker actions like it's shown in the xref:action_examples/containers.adoc#example-control-containers[container start/stop example].

+ 1 - 1
docs/modules/ROOT/partials/install/container.adoc

@@ -2,5 +2,5 @@
 ====
 ====
 OliveTin is supported when run as a Linux Container in many different ways, and lot of OliveTin users will assume that a Linux Container is the best way to install OliveTin because Linux Containers are really popular...
 OliveTin is supported when run as a Linux Container in many different ways, and lot of OliveTin users will assume that a Linux Container is the best way to install OliveTin because Linux Containers are really popular...
 
 
-However, it is very common that some OliveTin use cases become overcomplicated when a container is used, compared to running as a native service. Read the <<install-container-vs-service>> document to understand if a container or a service is right for your use case.
+However, it is very common that some OliveTin use cases become overcomplicated when a container is used, compared to running as a native service. Read the xref:install/container_vs_service.adoc[containers vs services] document to understand if a container or a service is right for your use case.
 ====
 ====

+ 0 - 25
frontend/.eslintrc.json

@@ -1,25 +0,0 @@
-{
-    "env": {
-        "browser": true,
-        "es2021": true
-    },
-    "extends": [
-        "plugin:vue/recommended",
-        "standard"
-    ],
-    "parser": "vue-eslint-parser",
-    "parserOptions": {
-        "ecmaVersion": 12,
-        "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"
-    }
-}

+ 9 - 8
frontend/Makefile

@@ -1,11 +1,12 @@
 define delete-files
 define delete-files
-	python -c "import shutil;shutil.rmtree('$(1)', ignore_errors=True)"
+	python3 -c "import shutil;shutil.rmtree('$(1)', ignore_errors=True)"
 endef
 endef
 
 
-codestyle:
-	npm install
-	npx eslint --fix main.js js/* resources/vue
-	npx stylelint style.css
+codestyle: deps
+	npm run lint
+
+codestyle-fix: deps
+	npm run lint:fix
 
 
 unittests: deps
 unittests: deps
 	npm test
 	npm test
@@ -14,11 +15,11 @@ clean:
 	$(call delete-files,dist)
 	$(call delete-files,dist)
 
 
 deps:
 deps:
-	npm install
+	npm ci
 
 
 build:
 build:
-	npx vite build
+	npm run build
 
 
 dist: deps clean build
 dist: deps clean build
 
 
-.PHONY: codestyle unittests
+.PHONY: codestyle codestyle-fix unittests deps build dist clean

+ 31 - 0
frontend/eslint.config.mjs

@@ -0,0 +1,31 @@
+import neostandard from 'neostandard'
+import pluginVue from 'eslint-plugin-vue'
+
+export default [
+	{
+		ignores: [
+			'dist/**',
+			'node_modules/**',
+			'resources/scripts/gen/**'
+		]
+	},
+	...neostandard({
+		env: ['browser'],
+		noJsx: true
+	}),
+	...pluginVue.configs['flat/recommended'],
+	{
+		files: ['**/*.{js,mjs,vue}'],
+		languageOptions: {
+			ecmaVersion: 'latest',
+			sourceType: 'module'
+		},
+		rules: {
+			'@stylistic/no-tabs': 'off',
+			'@stylistic/no-mixed-spaces-and-tabs': 'off',
+			'vue/multi-word-component-names': 'off',
+			'vue/require-default-prop': 'off',
+			'vue/no-v-html': 'warn'
+		}
+	}
+]

+ 0 - 2
frontend/index.html

@@ -8,8 +8,6 @@
 
 
 		<title>OliveTin</title>
 		<title>OliveTin</title>
 
 
-		<link rel = "stylesheet" href = "node_modules/@xterm/xterm/css/xterm.css" />
-
 		<link rel = "shortcut icon" type = "image/png" href = "OliveTinLogo.png" />
 		<link rel = "shortcut icon" type = "image/png" href = "OliveTinLogo.png" />
 
 
 		<link rel = "apple-touch-icon" sizes="57x57" href="OliveTinLogo-57px.png" />
 		<link rel = "apple-touch-icon" sizes="57x57" href="OliveTinLogo-57px.png" />

+ 1 - 0
frontend/js/OutputTerminal.js

@@ -1,3 +1,4 @@
+import '@xterm/xterm/css/xterm.css'
 import { Terminal } from '@xterm/xterm'
 import { Terminal } from '@xterm/xterm'
 import { FitAddon } from '@xterm/addon-fit'
 import { FitAddon } from '@xterm/addon-fit'
 import { WebLinksAddon } from '@xterm/addon-web-links'
 import { WebLinksAddon } from '@xterm/addon-web-links'

+ 17 - 38
frontend/main.js

@@ -1,6 +1,6 @@
 'use strict'
 'use strict'
 
 
-import 'femtocrank/style.css'
+import 'picocrank/styles.css'
 import 'femtocrank/dark.css'
 import 'femtocrank/dark.css'
 import './style.css'
 import './style.css'
 
 
@@ -18,48 +18,21 @@ import router from './resources/vue/router.js'
 import App from './resources/vue/App.vue'
 import App from './resources/vue/App.vue'
 
 
 import { initWebsocket } from './js/websocket.js'
 import { initWebsocket } from './js/websocket.js'
-import combinedTranslations from '../lang/combined_output.json'
-
-function getSelectedLanguage () {
-  const storedLanguage = localStorage.getItem('olivetin-language')
-
-  if (storedLanguage && storedLanguage !== 'auto') {
-    return storedLanguage
-  }
-
-  if (storedLanguage === 'auto') {
-    localStorage.removeItem('olivetin-language')
-  }
-
-  if (navigator.languages && navigator.languages.length > 0) {
-    const available = Object.keys(combinedTranslations.messages || {})
-
-    for (const candidate of navigator.languages) {
-      const lowerCandidate = candidate.toLowerCase()
-      const exact = available.find(locale => locale.toLowerCase() === lowerCandidate)
-
-      if (exact) {
-        return exact
-      }
-
-      const prefix = available.find(locale => locale.toLowerCase().startsWith(lowerCandidate.split('-')[0] + '-'))
-
-      if (prefix) {
-        return prefix
-      }
-    }
-  }
-
-  return 'en'
-}
+import { getSelectedLocale, loadInitialMessages } from './resources/vue/i18n.js'
+import { applyThemeStyles, getStoredThemePreference } from './resources/vue/utils/themeLoader.js'
 
 
 async function initClient () {
 async function initClient () {
   const transport = createConnectTransport({
   const transport = createConnectTransport({
     baseUrl: window.location.protocol + '//' + window.location.host + '/api/'
     baseUrl: window.location.protocol + '//' + window.location.host + '/api/'
   })
   })
+  const locale = getSelectedLocale()
 
 
   window.client = createClient(OliveTinApiService, transport)
   window.client = createClient(OliveTinApiService, transport)
-  window.initResponse = await window.client.init({})
+  const [initResponse, messages] = await Promise.all([
+    window.client.init({}),
+    loadInitialMessages(locale)
+  ])
+  window.initResponse = initResponse
 
 
   if (window.initResponse.enableCustomJs) {
   if (window.initResponse.enableCustomJs) {
     const script = document.createElement('script')
     const script = document.createElement('script')
@@ -71,9 +44,9 @@ async function initClient () {
 
 
   const i18nSettings = createI18n({
   const i18nSettings = createI18n({
     legacy: false,
     legacy: false,
-    locale: getSelectedLanguage(),
+    locale,
     fallbackLocale: 'en',
     fallbackLocale: 'en',
-    messages: combinedTranslations.messages,
+    messages,
     postTranslation: (translated) => {
     postTranslation: (translated) => {
       const params = new URLSearchParams(window.location.search)
       const params = new URLSearchParams(window.location.search)
 
 
@@ -118,6 +91,12 @@ async function main () {
   try {
   try {
     const i18nSettings = await initClient()
     const i18nSettings = await initClient()
 
 
+    try {
+      await applyThemeStyles(getStoredThemePreference())
+    } catch (err) {
+      console.warn('Failed to load theme CSS:', err)
+    }
+
     initWebsocket()
     initWebsocket()
 
 
     setupVue(i18nSettings)
     setupVue(i18nSettings)

Diff do ficheiro suprimidas por serem muito extensas
+ 635 - 256
frontend/package-lock.json


+ 21 - 25
frontend/package.json

@@ -3,42 +3,38 @@
 	"version": "1.0.0",
 	"version": "1.0.0",
 	"description": "The WebUI for OliveTin",
 	"description": "The WebUI for OliveTin",
 	"repository": "https://github.com/OliveTin/OliveTin",
 	"repository": "https://github.com/OliveTin/OliveTin",
-	"source": "index.html",
+	"type": "module",
 	"devDependencies": {
 	"devDependencies": {
-		"eslint-plugin-vue": "^10.10.0",
-		"process": "^0.11.10",
-		"stylelint": "^17.14.1",
-		"stylelint-config-standard": "^40.0.0"
+		"@vitejs/plugin-vue": "^6.0.8",
+		"eslint": "^9.39.5",
+		"eslint-plugin-vue": "^10.11.0",
+		"neostandard": "^0.13.0",
+		"stylelint": "^17.15.0",
+		"stylelint-config-standard": "^40.0.0",
+		"vite": "^8.2.2"
 	},
 	},
 	"scripts": {
 	"scripts": {
+		"build": "vite build",
+		"dev": "vite",
+		"lint": "eslint main.js js/* resources/vue vite.config.mjs && stylelint \"style.css\" \"themes/**/*.css\"",
+		"lint:fix": "eslint --fix main.js js/* resources/vue vite.config.mjs && stylelint --fix \"style.css\" \"themes/**/*.css\"",
+		"preview": "vite preview",
 		"test": "node --test resources/vue/components/*.test.mjs resources/vue/utils/*.test.mjs resources/vue/stores/*.test.mjs"
 		"test": "node --test resources/vue/components/*.test.mjs resources/vue/utils/*.test.mjs resources/vue/stores/*.test.mjs"
 	},
 	},
-	"author": "",
-	"parcelIgnore": [
-		"theme.css",
-		"OliveTinLogo.png",
-		"OliveTinLogo-57px.png",
-		"OliveTinLogo-120px.png",
-		"OliveTinLogo-180px.png"
-	],
 	"license": "AGPL-3.0-only",
 	"license": "AGPL-3.0-only",
 	"dependencies": {
 	"dependencies": {
-		"@connectrpc/connect": "^2.1.2",
-		"@connectrpc/connect-web": "^2.1.2",
-		"@hugeicons/core-free-icons": "^4.2.3",
-		"@hugeicons/vue": "^1.0.7",
-		"@vitejs/plugin-vue": "^6.0.8",
+		"@connectrpc/connect": "^2.2.0",
+		"@connectrpc/connect-web": "^2.2.0",
+		"@hugeicons/core-free-icons": "^4.3.2",
+		"@hugeicons/vue": "^1.0.8",
 		"@xterm/addon-fit": "^0.11.0",
 		"@xterm/addon-fit": "^0.11.0",
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/xterm": "^6.0.0",
 		"@xterm/xterm": "^6.0.0",
 		"iconify-icon": "^3.0.2",
 		"iconify-icon": "^3.0.2",
-		"picocrank": "^1.22.1",
-		"standard": "^17.1.2",
-		"unplugin-vue-components": "^32.1.0",
-		"vite": "^8.2.1",
-		"vue": "^3.5.41",
-		"vue-i18n": "^11.4.8",
-		"vue-router": "^5.2.0"
+		"picocrank": "^1.29.0",
+		"vue": "^3.5.42",
+		"vue-i18n": "^11.4.10",
+		"vue-router": "^5.3.1"
 	},
 	},
 	"engines": {
 	"engines": {
 		"node": "^20.19.0 || >=22.12.0"
 		"node": "^20.19.0 || >=22.12.0"

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

@@ -1,4 +1,4 @@
-// @generated by protoc-gen-es v2.13.0
+// @generated by protoc-gen-es v2.14.1
 // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
 // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
 /* eslint-disable */
 /* eslint-disable */
 
 

+ 1 - 1
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js

@@ -1,4 +1,4 @@
-// @generated by protoc-gen-es v2.13.0
+// @generated by protoc-gen-es v2.14.1
 // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
 // @generated from file olivetin/api/v1/olivetin.proto (package olivetin.api.v1, syntax proto3)
 /* eslint-disable */
 /* eslint-disable */
 
 

+ 73 - 25
frontend/resources/vue/ActionButton.vue

@@ -199,6 +199,49 @@ function consumeAndFlashPendingResult () {
 // Timestamps
 // Timestamps
 const updateIterationTimestamp = ref(0)
 const updateIterationTimestamp = ref(0)
 
 
+function isBindingBusy () {
+  const id = bindingId.value
+  if (!id) {
+    return false
+  }
+
+  const state = bindingExecutionState[id]
+  return !!(state?.hasRunning || state?.hasQueued)
+}
+
+function applyCanExecFromServer (json) {
+  if (json?.canExec === undefined) {
+    return
+  }
+
+  canExec.value = json.canExec
+
+  if (!isBindingBusy()) {
+    isDisabled.value = !json.canExec
+  }
+}
+
+async function refreshCanExecFromServer () {
+  const id = bindingId.value
+  if (!id || !window.client) {
+    return
+  }
+
+  try {
+    const response = await window.client.getActionBinding({ bindingId: id })
+    if (response?.action) {
+      applyCanExecFromServer(response.action)
+      updateFromJson(response.action)
+    }
+  } catch (err) {
+    console.error('Failed to refresh action exec permission:', err)
+  }
+}
+
+function onEntityOrConfigChanged () {
+  refreshCanExecFromServer()
+}
+
 function constructFromJson (json) {
 function constructFromJson (json) {
   updateIterationTimestamp.value = 0
   updateIterationTimestamp.value = 0
 
 
@@ -206,7 +249,6 @@ function constructFromJson (json) {
 
 
   bindingId.value = json.bindingId
   bindingId.value = json.bindingId
   title.value = json.title
   title.value = json.title
-  canExec.value = json.canExec
   popupOnStart.value = json.popupOnStart
   popupOnStart.value = json.popupOnStart
 
 
   if (popupOnStart.value.includes('execution-dialog')) {
   if (popupOnStart.value.includes('execution-dialog')) {
@@ -217,7 +259,7 @@ function constructFromJson (json) {
     navigateOnStart.value = 'arg'
     navigateOnStart.value = 'arg'
   }
   }
 
 
-  isDisabled.value = !json.canExec
+  applyCanExecFromServer(json)
   displayTitle.value = title.value
   displayTitle.value = title.value
   glyph.value = json.icon ?? ''
   glyph.value = json.icon ?? ''
   // Initialize rate limit from action data (parse datetime string)
   // Initialize rate limit from action data (parse datetime string)
@@ -231,9 +273,9 @@ function constructFromJson (json) {
   if (bindingId.value) {
   if (bindingId.value) {
     rateLimits[bindingId.value] = rateLimitExpires.value
     rateLimits[bindingId.value] = rateLimitExpires.value
     setBindingExecutionState(
     setBindingExecutionState(
-	  bindingId.value,
-	  !!json.hasRunningInstance,
-	  !!json.hasQueuedInstance
+      bindingId.value,
+      !!json.hasRunningInstance,
+      !!json.hasQueuedInstance
     )
     )
   }
   }
   updateRateLimitStatus()
   updateRateLimitStatus()
@@ -243,6 +285,8 @@ function updateFromJson (json) {
   // Fields that should not be updated
   // Fields that should not be updated
   // title - as the callback URL relies on it
   // title - as the callback URL relies on it
 
 
+  applyCanExecFromServer(json)
+
   // Update rate limiting if changed (parse datetime string)
   // Update rate limiting if changed (parse datetime string)
   if (json.datetimeRateLimitExpires) {
   if (json.datetimeRateLimitExpires) {
     const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
     const date = new Date(json.datetimeRateLimitExpires.replace(' ', 'T'))
@@ -266,8 +310,8 @@ function updateRateLimitStatus () {
     isRateLimited.value = false
     isRateLimited.value = false
     rateLimitMessage.value = ''
     rateLimitMessage.value = ''
     if (rateLimitInterval.value) {
     if (rateLimitInterval.value) {
-	  clearInterval(rateLimitInterval.value)
-	  rateLimitInterval.value = null
+      clearInterval(rateLimitInterval.value)
+      rateLimitInterval.value = null
     }
     }
     return
     return
   }
   }
@@ -281,8 +325,8 @@ function updateRateLimitStatus () {
     rateLimitMessage.value = ''
     rateLimitMessage.value = ''
     rateLimitExpires.value = 0
     rateLimitExpires.value = 0
     if (rateLimitInterval.value) {
     if (rateLimitInterval.value) {
-	  clearInterval(rateLimitInterval.value)
-	  rateLimitInterval.value = null
+      clearInterval(rateLimitInterval.value)
+      rateLimitInterval.value = null
     }
     }
   } else {
   } else {
     // Still rate limited
     // Still rate limited
@@ -292,9 +336,9 @@ function updateRateLimitStatus () {
 
 
     // Set up interval to update every second
     // Set up interval to update every second
     if (!rateLimitInterval.value) {
     if (!rateLimitInterval.value) {
-	  rateLimitInterval.value = setInterval(() => {
+      rateLimitInterval.value = setInterval(() => {
         updateRateLimitStatus()
         updateRateLimitStatus()
-	  }, 1000)
+      }, 1000)
     }
     }
   }
   }
 }
 }
@@ -316,12 +360,12 @@ async function handleClick () {
     const bindingId = props.actionData.bindingId
     const bindingId = props.actionData.bindingId
     const prefilled = props.prefilledArguments || {}
     const prefilled = props.prefilledArguments || {}
     if (Object.keys(prefilled).length > 0) {
     if (Object.keys(prefilled).length > 0) {
-	  router.push({
+      router.push({
         path: `/actionBinding/${bindingId}/argumentForm`,
         path: `/actionBinding/${bindingId}/argumentForm`,
         state: { prefilledArguments: prefilled }
         state: { prefilledArguments: prefilled }
-	  })
+      })
     } else {
     } else {
-	  router.push(`/actionBinding/${bindingId}/argumentForm`)
+      router.push(`/actionBinding/${bindingId}/argumentForm`)
     }
     }
   } else {
   } else {
     await startAction()
     await startAction()
@@ -395,7 +439,7 @@ async function startAction (actionArgs) {
   stopButtonResultWatch = watch(
   stopButtonResultWatch = watch(
     () => buttonResults[startActionArgs.uniqueTrackingId],
     () => buttonResults[startActionArgs.uniqueTrackingId],
     (newResult, oldResult) => {
     (newResult, oldResult) => {
-	  onLogEntryChanged(newResult)
+      onLogEntryChanged(newResult)
     }
     }
   )
   )
 
 
@@ -406,11 +450,11 @@ async function startAction (actionArgs) {
     const trackingId = response.executionTrackingId || startActionArgs.uniqueTrackingId
     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}`)
+      router.push(`/logs/${trackingId}`)
     }
     }
 
 
     if (!connectionState.connected) {
     if (!connectionState.connected) {
-	  await pollExecutionUntilDone(trackingId)
+      await pollExecutionUntilDone(trackingId)
     }
     }
   } catch (err) {
   } catch (err) {
     stopWatchingButtonResult()
     stopWatchingButtonResult()
@@ -502,20 +546,22 @@ function onExecStatusChanged () {
 
 
 onMounted(() => {
 onMounted(() => {
   constructFromJson(props.actionData)
   constructFromJson(props.actionData)
+  window.addEventListener('EventEntityChanged', onEntityOrConfigChanged)
+  window.addEventListener('EventConfigChanged', onEntityOrConfigChanged)
 
 
   // Watch the central rate limit store for updates to this button's bindingId
   // 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 the entire rateLimits object to ensure reactivity with dynamic keys
   watch(
   watch(
     rateLimits,
     rateLimits,
     () => {
     () => {
-	  const id = bindingId.value
-	  if (id && rateLimits[id] !== undefined) {
+      const id = bindingId.value
+      if (id && rateLimits[id] !== undefined) {
         const newExpires = rateLimits[id]
         const newExpires = rateLimits[id]
         if (newExpires !== rateLimitExpires.value) {
         if (newExpires !== rateLimitExpires.value) {
-		  rateLimitExpires.value = newExpires
-		  updateRateLimitStatus()
+          rateLimitExpires.value = newExpires
+          updateRateLimitStatus()
         }
         }
-	  }
+      }
     },
     },
     { deep: true }
     { deep: true }
   )
   )
@@ -524,9 +570,9 @@ onMounted(() => {
   watch(
   watch(
     () => pendingBindingFlash[bindingId.value],
     () => pendingBindingFlash[bindingId.value],
     (pending) => {
     (pending) => {
-	  if (pending) {
+      if (pending) {
         consumeAndFlashPendingResult()
         consumeAndFlashPendingResult()
-	  }
+      }
     },
     },
     { immediate: true }
     { immediate: true }
   )
   )
@@ -534,6 +580,8 @@ onMounted(() => {
 
 
 onUnmounted(() => {
 onUnmounted(() => {
   isComponentMounted.value = false
   isComponentMounted.value = false
+  window.removeEventListener('EventEntityChanged', onEntityOrConfigChanged)
+  window.removeEventListener('EventConfigChanged', onEntityOrConfigChanged)
   if (rateLimitInterval.value) {
   if (rateLimitInterval.value) {
     clearInterval(rateLimitInterval.value)
     clearInterval(rateLimitInterval.value)
     rateLimitInterval.value = null
     rateLimitInterval.value = null
@@ -545,7 +593,7 @@ watch(
   (newData) => {
   (newData) => {
     updateFromJson(newData)
     updateFromJson(newData)
     if (newData?.icon !== undefined) {
     if (newData?.icon !== undefined) {
-	  glyph.value = newData.icon ?? ''
+      glyph.value = newData.icon ?? ''
     }
     }
   },
   },
   { deep: true }
   { deep: true }

+ 242 - 246
frontend/resources/vue/App.vue

@@ -1,198 +1,179 @@
 <template>
 <template>
-  <Header
-    :title="pageTitle"
-    :logo-url="logoUrl"
-    :sidebar-enabled="sidebarEnabled"
-    :top-bar-enabled="topbarEnabled"
-    :navigation="navigation"
-    @toggle-sidebar="toggleSidebar"
-  >
-    <template #toolbar>
-      <QuickSearch
-        v-if="!loginRequired && headerSearchEnabled"
-        :items="searchIndexItems"
-        :auto-import-routes="false"
-        :search-fields="['title', 'description', 'category']"
-        :max-results="15"
-        placeholder="Search actions, dashboards, entities…"
-      />
-      <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
-          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"
+  <div class="layout-fixed-header">
+    <Header
+      :title="pageTitle"
+      :logo-url="logoUrl"
+      :breadcrumbs="false"
+      :sidebar-enabled="sidebarEnabled"
+      :top-bar-enabled="topbarEnabled"
+      :navigation="navigation"
+      :username="headerUsername"
+      :login-route="headerLoginRoute"
+      @toggle-sidebar="toggleSidebar"
+      @user-click="goToUserControlPanel"
+    >
+      <template #toolbar>
+        <QuickSearch
+          v-if="!loginRequired && headerSearchEnabled"
+          :items="searchIndexItems"
+          :auto-import-routes="false"
+          :search-fields="['title', 'description', 'category']"
+          :max-results="15"
+          placeholder="Search actions, dashboards, entities…"
+        />
+        <div
+          v-if="bannerMessage"
+          id="banner"
+          :style="bannerCss"
         >
         >
-          <span id="username-text">{{ username }}</span>
-        </router-link>
-        <HugeiconsIcon
-          v-if="isLoggedIn"
-          :icon="UserCircle02Icon"
-          width="1.5em"
-          height="1.5em"
+          <p>{{ bannerMessage }}</p>
+        </div>
+        <ConnectionBanner />
+      </template>
+    </Header>
+
+    <div id="layout">
+      <Navigation ref="navigation">
+        <Sidebar
+          v-if="sidebarEnabled && showNavigation"
+          id="mainnav"
+          ref="sidebar"
         />
         />
-      </div>
-    </template>
-  </Header>
-
-  <div id="layout">
-    <Navigation ref="navigation">
-      <Sidebar
-        v-if="sidebarEnabled && showNavigation"
-        id="mainnav"
-        ref="sidebar"
-      />
-    </Navigation>
-
-    <div
-      id="content"
-      :initial-martial-complete="hasLoaded"
-    >
-      <main title="Main content">
-        <router-view :key="$route.fullPath" />
-      </main>
+      </Navigation>
 
 
-      <footer
-        v-if="showFooter"
-        title="footer"
+      <div
+        id="content"
+        :initial-martial-complete="hasLoaded"
       >
       >
-        <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>
+        <main title="Main content">
+          <router-view :key="$route.fullPath" />
+        </main>
 
 
-          <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 v-if="availableThemes.length > 1">
+        <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>
+
+            <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 v-if="availableThemes.length > 1">
+              <a
+                href="#"
+                @click.prevent="openThemeDialog"
+              >{{ currentThemeName }}</a>
+            </span>
+          </p>
+          <p v-if="showVersionNumber">
             <a
             <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>
+              id="available-version"
+              href="http://olivetin.app"
+              target="_blank"
+              hidden
+            >?</a>
+          </p>
+        </footer>
+      </div>
     </div>
     </div>
-  </div>
 
 
-  <dialog
-    ref="languageDialog"
-    class="language-dialog"
-    @click="handleLanguageDialogClick"
-  >
-    <div
-      class="dialog-content"
-      @click.stop
+    <dialog
+      ref="languageDialog"
+      class="language-dialog"
+      @click="handleLanguageDialogClick"
     >
     >
-      <h2>{{ t('language-dialog.title') }}</h2>
-      <select
-        v-model="selectedLanguage"
-        class="language-select"
-        @change="changeLanguage"
+      <div
+        class="dialog-content"
+        @click.stop
       >
       >
-        <option
-          v-for="(name, code) in availableLanguages"
-          :key="code"
-          :value="code"
+        <h2>{{ t('language-dialog.title') }}</h2>
+        <select
+          v-model="selectedLanguage"
+          class="language-select"
+          @change="changeLanguage"
         >
         >
-          {{ 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>
+          <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>
       </div>
-    </div>
-  </dialog>
-
-  <dialog
-    ref="themeDialog"
-    class="theme-dialog"
-    @click="handleThemeDialogClick"
-  >
-    <div
-      class="dialog-content"
-      @click.stop
+    </dialog>
+
+    <dialog
+      ref="themeDialog"
+      class="theme-dialog"
+      @click="handleThemeDialogClick"
     >
     >
-      <h2>{{ t('theme-dialog.title') }}</h2>
-      <select
-        v-model="selectedTheme"
-        class="language-select"
-        @change="changeTheme"
+      <div
+        class="dialog-content"
+        @click.stop
       >
       >
-        <option value="">
-          {{ t('theme-dialog.default') }}
-        </option>
-        <option
-          v-for="theme in availableThemes"
-          :key="theme"
-          :value="theme"
+        <h2>{{ t('theme-dialog.title') }}</h2>
+        <select
+          v-model="selectedTheme"
+          class="language-select"
+          @change="changeTheme"
         >
         >
-          {{ theme }}
-        </option>
-      </select>
-      <div class="dialog-buttons">
-        <button @click="closeThemeDialog">
-          {{ t('theme-dialog.close') }}
-        </button>
+          <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>
       </div>
-    </div>
-  </dialog>
+    </dialog>
+  </div>
 </template>
 </template>
 
 
 <script setup>
 <script setup>
@@ -204,19 +185,19 @@ import Header from 'picocrank/vue/components/Header.vue'
 import QuickSearch from 'picocrank/vue/components/QuickSearch.vue'
 import QuickSearch from 'picocrank/vue/components/QuickSearch.vue'
 import ConnectionBanner from './components/ConnectionBanner.vue'
 import ConnectionBanner from './components/ConnectionBanner.vue'
 import { connectEventStreamIfNeeded } from '../../js/websocket.js'
 import { connectEventStreamIfNeeded } from '../../js/websocket.js'
-import { HugeiconsIcon } from '@hugeicons/vue'
-import { UserCircle02Icon, DashboardSquare01Icon } from '@hugeicons/core-free-icons'
+import { DashboardSquare01Icon } from '@hugeicons/core-free-icons'
 import logoUrl from '../../OliveTinLogo.png'
 import logoUrl from '../../OliveTinLogo.png'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
-import combinedTranslations from '../../../lang/combined_output.json'
+import { ensureLocaleMessages, resolveBrowserLocale } from './i18n.js'
 import { searchIndexItems, clearSearchIndex, indexSystemNavigation, indexSearchHints, indexRootDashboardEntries } from './stores/searchIndex.js'
 import { searchIndexItems, clearSearchIndex, indexSystemNavigation, indexSearchHints, indexRootDashboardEntries } from './stores/searchIndex.js'
+import { applyThemeStyles } from './utils/themeLoader.js'
 const { t } = useI18n()
 const { t } = useI18n()
 
 
 const router = useRouter()
 const router = useRouter()
 
 
 const sidebar = ref(null)
 const sidebar = ref(null)
 const navigation = ref(null)
 const navigation = ref(null)
-const username = ref('notset')
+const username = ref('')
 const isLoggedIn = ref(false)
 const isLoggedIn = ref(false)
 const currentVersion = ref('?')
 const currentVersion = ref('?')
 const pageTitle = ref('OliveTin')
 const pageTitle = ref('OliveTin')
@@ -239,6 +220,7 @@ const browserLanguages = ref([])
 const initialLanguagePreference = typeof window !== 'undefined' ? localStorage.getItem('olivetin-language') : null
 const initialLanguagePreference = typeof window !== 'undefined' ? localStorage.getItem('olivetin-language') : null
 const languagePreference = ref(initialLanguagePreference || 'auto')
 const languagePreference = ref(initialLanguagePreference || 'auto')
 const selectedLanguage = ref(languagePreference.value)
 const selectedLanguage = ref(languagePreference.value)
+let latestLanguageChange = 0
 
 
 const themeDialog = ref(null)
 const themeDialog = ref(null)
 const availableThemes = ref([])
 const availableThemes = ref([])
@@ -253,7 +235,8 @@ const availableLanguages = {
   'de-DE': 'Deutsch',
   'de-DE': 'Deutsch',
   'es-ES': 'Español',
   'es-ES': 'Español',
   'it-IT': 'Italiano',
   'it-IT': 'Italiano',
-  'zh-Hans-CN': '简体中文'
+  'zh-Hans-CN': '简体中文',
+  'zh-Hant-TW': '繁體中文'
 }
 }
 
 
 // Computed property to get current language display name
 // Computed property to get current language display name
@@ -282,29 +265,13 @@ const sidebarEnabled = computed(() => {
   return sectionNavigationStyle.value !== 'topbar' && showNavigation.value
   return sectionNavigationStyle.value !== 'topbar' && showNavigation.value
 })
 })
 
 
-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()
-
-      // 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
-      }
-    }
-  }
+const headerUsername = computed(() => {
+  return isLoggedIn.value ? username.value : ''
+})
 
 
-  return 'en'
-}
+const headerLoginRoute = computed(() => {
+  return showLoginLink.value ? { name: 'Login' } : null
+})
 
 
 function toggleSidebar () {
 function toggleSidebar () {
   if (sidebar.value && showNavigation.value) {
   if (sidebar.value && showNavigation.value) {
@@ -312,7 +279,11 @@ function toggleSidebar () {
   }
   }
 }
 }
 
 
-function updateHeaderFromInit () {
+function goToUserControlPanel () {
+  router.push({ name: 'UserInformation' })
+}
+
+async function updateHeaderFromInit () {
   if (!window.initResponse) {
   if (!window.initResponse) {
     return
     return
   }
   }
@@ -346,7 +317,9 @@ function updateHeaderFromInit () {
   loadCustomJsIfEnabled()
   loadCustomJsIfEnabled()
 
 
   renderNavigation()
   renderNavigation()
-  applyTheme()
+  applyTheme().catch((err) => {
+    console.warn('Failed to load theme CSS:', err)
+  })
 
 
   if (loginRequired.value) {
   if (loginRequired.value) {
     connectEventStreamIfNeeded()
     connectEventStreamIfNeeded()
@@ -475,10 +448,33 @@ function addSystemNavLinks () {
   navigation.value.addSection(t('nav.system'))
   navigation.value.addSection(t('nav.system'))
 
 
   for (const link of systemLinks) {
   for (const link of systemLinks) {
-    navigation.value.addRouterLink(link.routeName, link.title, link.options || {})
+    addSystemRouterNavLink(link.routeName, link.title, link.options || {})
   }
   }
 }
 }
 
 
+function addSystemRouterNavLink (routeName, title, options = {}) {
+  const foundRoute = router.getRoutes().find((candidate) => candidate.name === routeName)
+  if (!foundRoute) {
+    console.warn(`Route "${routeName}" not found`)
+    return
+  }
+
+  const navLink = {
+    name: routeName,
+    title,
+    path: foundRoute.path,
+    to: foundRoute.path,
+    icon: foundRoute.meta?.icon || DashboardSquare01Icon,
+    type: 'route'
+  }
+
+  if (options.count != null && options.count > 0) {
+    navLink.count = options.count
+  }
+
+  navigation.value.addNavigationLink(navLink)
+}
+
 function openLanguageDialog () {
 function openLanguageDialog () {
   selectedLanguage.value = languagePreference.value
   selectedLanguage.value = languagePreference.value
 
 
@@ -499,19 +495,29 @@ function closeLanguageDialog () {
   }
   }
 }
 }
 
 
-function changeLanguage () {
+async function changeLanguage () {
   if (!window.i18n || !selectedLanguage.value) {
   if (!window.i18n || !selectedLanguage.value) {
     return
     return
   }
   }
 
 
-  if (selectedLanguage.value === 'auto') {
+  const languageChange = ++latestLanguageChange
+  const requestedPreference = selectedLanguage.value
+  const requestedLocale = requestedPreference === 'auto' ? resolveBrowserLocale() : requestedPreference
+
+  await ensureLocaleMessages(window.i18n, requestedLocale)
+
+  if (languageChange !== latestLanguageChange) {
+    return
+  }
+
+  window.i18n.locale.value = requestedLocale
+
+  if (requestedPreference === 'auto') {
     localStorage.removeItem('olivetin-language')
     localStorage.removeItem('olivetin-language')
     languagePreference.value = 'auto'
     languagePreference.value = 'auto'
-    window.i18n.locale.value = normalizeBrowserLanguage()
   } else {
   } else {
-    window.i18n.locale.value = selectedLanguage.value
-    localStorage.setItem('olivetin-language', selectedLanguage.value)
-    languagePreference.value = selectedLanguage.value
+    localStorage.setItem('olivetin-language', requestedPreference)
+    languagePreference.value = requestedPreference
   }
   }
 
 
   // Update navigation with new translations
   // Update navigation with new translations
@@ -543,7 +549,7 @@ function closeThemeDialog () {
   }
   }
 }
 }
 
 
-function changeTheme () {
+async function changeTheme () {
   if (!selectedTheme.value || selectedTheme.value === '') {
   if (!selectedTheme.value || selectedTheme.value === '') {
     localStorage.removeItem('olivetin-theme')
     localStorage.removeItem('olivetin-theme')
     themePreference.value = ''
     themePreference.value = ''
@@ -552,25 +558,28 @@ function changeTheme () {
     themePreference.value = selectedTheme.value
     themePreference.value = selectedTheme.value
   }
   }
 
 
-  applyTheme()
+  await applyTheme()
   closeThemeDialog()
   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);'
+async function applyTheme () {
+  try {
+    await applyThemeStyles(themePreference.value)
+  } catch (err) {
+    console.warn('Failed to load theme CSS:', err)
+    localStorage.removeItem('olivetin-theme')
+    themePreference.value = ''
+    selectedTheme.value = ''
+    try {
+      await applyThemeStyles('')
+    } catch (fallbackErr) {
+      console.warn('Failed to load default theme CSS:', fallbackErr)
+      const themeStyle = document.getElementById('theme-style')
+      if (themeStyle) {
+        themeStyle.textContent = ''
+      }
+      document.body.removeAttribute('loaded-theme')
+    }
   }
   }
 }
 }
 
 
@@ -605,8 +614,8 @@ function handleThemeDialogClick (event) {
 
 
 window.updateHeaderFromInit = updateHeaderFromInit
 window.updateHeaderFromInit = updateHeaderFromInit
 
 
-onMounted(() => {
-  updateHeaderFromInit()
+onMounted(async () => {
+  await updateHeaderFromInit()
 
 
   // Initialize selected language from stored preference
   // Initialize selected language from stored preference
   selectedLanguage.value = languagePreference.value
   selectedLanguage.value = languagePreference.value
@@ -621,19 +630,6 @@ onMounted(() => {
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
-.user-info span {
-    margin-left: 1em;
-}
-
-.user-link {
-    text-decoration: none;
-    color: inherit;
-}
-
-.user-link:hover {
-    text-decoration: underline;
-}
-
 .language-dialog,
 .language-dialog,
 .theme-dialog {
 .theme-dialog {
     border: 1px solid var(--border-color, #ccc);
     border: 1px solid var(--border-color, #ccc);

+ 16 - 4
frontend/resources/vue/Dashboard.vue

@@ -47,7 +47,8 @@
         class="back-button-container"
         class="back-button-container"
       >
       >
         <button
         <button
-          class="back-button"
+          type="button"
+          class="back-button inline-icon"
           @click="goBack"
           @click="goBack"
         >
         >
           <HugeiconsIcon
           <HugeiconsIcon
@@ -55,7 +56,7 @@
             width="1.2em"
             width="1.2em"
             height="1.2em"
             height="1.2em"
           />
           />
-          <span>Back</span>
+          Back
         </button>
         </button>
       </div>
       </div>
       <h2>{{ dashboard.title }}</h2>
       <h2>{{ dashboard.title }}</h2>
@@ -76,7 +77,8 @@
         class="back-button-container"
         class="back-button-container"
       >
       >
         <button
         <button
-          class="back-button"
+          type="button"
+          class="back-button inline-icon"
           @click="goBack"
           @click="goBack"
         >
         >
           <HugeiconsIcon
           <HugeiconsIcon
@@ -84,7 +86,7 @@
             width="1.2em"
             width="1.2em"
             height="1.2em"
             height="1.2em"
           />
           />
-          <span>Back</span>
+          Back
         </button>
         </button>
       </div>
       </div>
       <div
       <div
@@ -290,8 +292,16 @@ function waitForInitAndLoadDashboard () {
   }
   }
 }
 }
 
 
+function refreshDashboardAfterLiveUpdate () {
+  if (window.initResponse) {
+    getDashboard()
+  }
+}
+
 onMounted(() => {
 onMounted(() => {
   waitForInitAndLoadDashboard()
   waitForInitAndLoadDashboard()
+  window.addEventListener('EventEntityChanged', refreshDashboardAfterLiveUpdate)
+  window.addEventListener('EventConfigChanged', refreshDashboardAfterLiveUpdate)
 })
 })
 
 
 watch(
 watch(
@@ -304,6 +314,8 @@ watch(
 
 
 onUnmounted(() => {
 onUnmounted(() => {
   document.body.removeAttribute('loaded-dashboard')
   document.body.removeAttribute('loaded-dashboard')
+  window.removeEventListener('EventEntityChanged', refreshDashboardAfterLiveUpdate)
+  window.removeEventListener('EventConfigChanged', refreshDashboardAfterLiveUpdate)
 
 
   // Clean up the timers when component is unmounted
   // Clean up the timers when component is unmounted
   if (loadingTimer) {
   if (loadingTimer) {

+ 40 - 41
frontend/resources/vue/components/ActionStatusDisplay.vue

@@ -1,14 +1,17 @@
 <template>
 <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>
+  <router-link
+    v-if="showQueueLink"
+    to="/logs/queue"
+    class="tag"
+    :class="statusTagClass"
+  >
+    {{ statusText }}
+  </router-link>
+  <span
+    v-else
+    class="tag"
+    :class="statusTagClass"
+  >{{ statusText }}{{ exitCodeText }}</span>
 </template>
 </template>
 
 
 <script setup>
 <script setup>
@@ -71,48 +74,44 @@ const showQueueLink = computed(() => {
   return props.linkQueuedStatus && isWaitingInQueue(props.logEntry)
   return props.linkQueuedStatus && isWaitingInQueue(props.logEntry)
 })
 })
 
 
-const statusClass = computed(() => {
+const statusTagClass = computed(() => {
   const logEntry = props.logEntry
   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'
+  if (!logEntry) {
+    return ''
+  }
+
+  if (!logEntry.executionFinished) {
+    if (isWaitingInQueue(logEntry)) {
+      return 'note'
     }
     }
+    return 'info'
   }
   }
-  return ''
+
+  if (logEntry.blocked) {
+    return 'status-blocked'
+  }
+  if (logEntry.timedOut) {
+    return ['warning', 'status-timeout']
+  }
+  if (logEntry.exitCode === 0) {
+    return ['good', 'status-success']
+  }
+  return ['error', 'status-nonzero-exit']
 })
 })
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
-.status-success {
-  color: var(--karma-good-fg);
-}
-
-.status-nonzero-exit {
-  color: var(--karma-bad-fg);
+.tag {
+  text-transform: none;
 }
 }
 
 
-.status-timeout {
-  color: var(--karma-warning-fg);
+.tag.status-blocked {
+  border-color: transparent;
+  background-color: color-mix(in srgb, #ca79ff 30%, var(--standout-bg-color));
+  color: var(--text-color);
 }
 }
 
 
-.status-blocked {
-  color: #ca79ff;
-}
-
-.queue-status-link {
-  color: #0d6efd;
+a.tag {
   text-decoration: none;
   text-decoration: none;
 }
 }
-
-.queue-status-link:hover {
-  text-decoration: underline;
-}
-
 </style>
 </style>

+ 0 - 64
frontend/resources/vue/components/Breadcrumbs.vue

@@ -1,64 +0,0 @@
-<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>
-</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;
-}
-
-a {
-    text-decoration: none;
-    padding: 0.4em;
-    border-radius: 0.2em;
-}
-
-a:hover {
-    text-decoration: underline;
-    background-color: #000;
-}
-
-</style>

+ 9 - 5
frontend/resources/vue/components/ChoiceChecklist.vue

@@ -142,20 +142,24 @@ function selectNone () {
   text-decoration: underline;
   text-decoration: underline;
 }
 }
 
 
-.choice-checklist-fieldset {
+fieldset.choice-checklist-fieldset {
   border: none;
   border: none;
-  display: grid;
-  gap: 0.5em 1em;
-  grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
+  display: flex;
+  flex-direction: column;
+  align-items: stretch;
+  gap: 0.5em;
   margin: 0;
   margin: 0;
   padding: 0;
   padding: 0;
+  width: 100%;
 }
 }
 
 
 .choice-checklist-item {
 .choice-checklist-item {
-  align-items: center;
   display: flex;
   display: flex;
+  align-items: center;
   gap: 0.4em;
   gap: 0.4em;
   margin: 0;
   margin: 0;
+  max-width: 20em;
+  width: 100%;
 }
 }
 
 
 .choice-checklist-item input[type="checkbox"] {
 .choice-checklist-item input[type="checkbox"] {

+ 1 - 1
frontend/resources/vue/components/DashboardComponent.vue

@@ -1,7 +1,7 @@
 <template>
 <template>
   <ActionButton
   <ActionButton
     v-if="component.type == 'link'"
     v-if="component.type == 'link'"
-    :key="component.title"
+    :key="component.action?.bindingId || component.title"
     :action-data="component.action"
     :action-data="component.action"
     :css-class="component.cssClass"
     :css-class="component.cssClass"
   />
   />

+ 5 - 1
frontend/resources/vue/components/EntityDefinitionSection.vue

@@ -1,5 +1,8 @@
 <template>
 <template>
-  <Section :padding="!hasTable">
+  <Section
+    :icon="CellsIcon"
+    :padding="!hasTable"
+  >
     <template #title>
     <template #title>
       <span class="section-title-with-icon">
       <span class="section-title-with-icon">
         Entity:
         Entity:
@@ -79,6 +82,7 @@
 
 
 <script setup>
 <script setup>
 import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
 import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
+import { CellsIcon } from '@hugeicons/core-free-icons'
 import Section from 'picocrank/vue/components/Section.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import ActionIconGlyph from './ActionIconGlyph.vue'
 import ActionIconGlyph from './ActionIconGlyph.vue'
 import EntityInstancesTable from './EntityInstancesTable.vue'
 import EntityInstancesTable from './EntityInstancesTable.vue'

+ 226 - 0
frontend/resources/vue/components/ExecutionLogsTable.vue

@@ -0,0 +1,226 @@
+<template>
+  <Table
+    :data="logs"
+    :headers="headers"
+    :table-id="tableId"
+    :show-pagination="false"
+    :filterable="false"
+    :loading="loading"
+    row-key="executionTrackingId"
+  >
+    <template #cell-timestamp="{ row }">
+      <span class="timestamp">{{ formatTimestamp(row.datetimeStarted) }}</span>
+    </template>
+
+    <template
+      v-if="variant === 'action-history'"
+      #cell-duration="{ row }"
+    >
+      <span class="duration">
+        <slot
+          name="duration"
+          :row="row"
+        >
+          {{ row.duration }}
+        </slot>
+      </span>
+    </template>
+
+    <template #cell-executionId="{ row }">
+      <router-link
+        :to="`/logs/${row.executionTrackingId}`"
+        class="execution-id-link"
+      >
+        <LogActionTitle
+          :justification="variant === 'action-history' ? row.justification : ''"
+        >
+          {{ row.executionTrackingId }}
+        </LogActionTitle>
+      </router-link>
+    </template>
+
+    <template
+      v-if="variant === 'standard'"
+      #cell-action="{ row }"
+    >
+      <ActionIconGlyph
+        class="icon"
+        :glyph="row.actionIcon"
+      />
+      <router-link
+        v-if="row.bindingId"
+        :to="{ name: 'ActionDetails', params: { actionId: row.bindingId } }"
+      >
+        <LogActionTitle
+          :action-title="row.actionTitle"
+          :justification="row.justification"
+        />
+      </router-link>
+      <LogActionTitle
+        v-else
+        :action-title="row.actionTitle"
+        :justification="row.justification"
+      />
+      <span
+        v-if="row.entityPrefix"
+        class="queue-entity annotation"
+      >
+        {{ row.entityPrefix }}
+      </span>
+    </template>
+
+    <template #cell-metadata="{ row }">
+      <span class="tags">
+        <span class="annotation">
+          <span class="annotation-key">User:</span>
+          <span class="annotation-val">{{ row.user }}</span>
+        </span>
+        <span
+          v-if="row.tags && row.tags.length > 0"
+          class="tag-list"
+        >
+          <span
+            v-for="tag in row.tags"
+            :key="tag"
+            class="tag"
+          >{{ tag }}</span>
+        </span>
+      </span>
+    </template>
+
+    <template #cell-status="{ row }">
+      <span class="exit-code">
+        <span
+          v-if="variant === 'standard' && row.queuePosition != null && !row.executionFinished && !row.executionStarted"
+          class="queue-position"
+        >
+          {{ t('logs.queue-position', { position: row.queuePosition }) }}
+        </span>
+        <ActionStatusDisplay
+          :log-entry="row"
+          :link-queued-status="true"
+        />
+      </span>
+    </template>
+  </Table>
+</template>
+
+<script setup>
+import { computed } from 'vue'
+import { useI18n } from 'vue-i18n'
+import Table from 'picocrank/vue/components/Table.vue'
+import ActionIconGlyph from './ActionIconGlyph.vue'
+import ActionStatusDisplay from './ActionStatusDisplay.vue'
+import LogActionTitle from './LogActionTitle.vue'
+
+const props = defineProps({
+  logs: {
+    type: Array,
+    required: true
+  },
+  variant: {
+    type: String,
+    default: 'standard',
+    validator: value => ['standard', 'action-history'].includes(value)
+  },
+  loading: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const { t } = useI18n()
+
+const tableId = computed(() =>
+  props.variant === 'action-history'
+    ? 'olivetin-execution-logs-action-history'
+    : 'olivetin-execution-logs-standard'
+)
+
+const headers = computed(() => {
+  if (props.variant === 'action-history') {
+    return [
+      { key: 'timestamp', label: 'Timestamp', sortable: false },
+      { key: 'duration', label: 'Duration', sortable: false },
+      { key: 'executionId', label: 'Execution ID', sortable: false },
+      { key: 'metadata', label: 'Metadata', sortable: false },
+      { key: 'status', label: 'Status', sortable: false }
+    ]
+  }
+
+  return [
+    { key: 'timestamp', label: t('logs.timestamp'), sortable: false },
+    { key: 'action', label: t('logs.action'), sortable: false },
+    { key: 'executionId', label: t('logs.execution-id'), sortable: false },
+    { key: 'metadata', label: t('logs.metadata'), sortable: false },
+    { key: 'status', label: t('logs.status'), sortable: false }
+  ]
+})
+
+function formatTimestamp (timestamp) {
+  if (!timestamp) {
+    return 'Unknown'
+  }
+  try {
+    return new Date(timestamp).toLocaleString()
+  } catch (err) {
+    return timestamp
+  }
+}
+</script>
+
+<style scoped>
+.timestamp {
+  font-family: monospace;
+  font-size: 0.875rem;
+  color: var(--muted-text-color, #666);
+}
+
+.duration {
+  font-size: 0.9rem;
+  color: var(--muted-text-color, #666);
+  white-space: nowrap;
+}
+
+.icon {
+  margin-right: 0.5rem;
+  font-size: 1.2em;
+}
+
+.annotation {
+  font-weight: 500;
+  font-size: smaller;
+}
+
+.queue-entity {
+  display: block;
+  margin-top: 0.25rem;
+  color: var(--muted-text-color, #666);
+}
+
+.tags {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 0.5rem;
+}
+
+.exit-code {
+  display: flex;
+  align-items: center;
+  gap: 0.5rem;
+}
+
+.queue-position {
+  white-space: nowrap;
+}
+
+.execution-id-link {
+  font-family: monospace;
+  font-size: 0.875rem;
+  text-decoration: none;
+}
+
+.execution-id-link:hover {
+  text-decoration: underline;
+}
+</style>

+ 29 - 29
frontend/resources/vue/components/actionIconGlyphHelpers.mjs

@@ -1,38 +1,38 @@
 const fallbackNamedHtmlEntities = {
 const fallbackNamedHtmlEntities = {
-	amp: '&',
-	apos: "'",
-	darr: '\u2193',
-	gt: '>',
-	laquo: '\u00ab',
-	larr: '\u2190',
-	nbsp: '\u00a0',
-	quot: '"',
-	raquo: '\u00bb',
-	rarr: '\u2192',
-	uarr: '\u2191',
+  amp: '&',
+  apos: "'",
+  darr: '\u2193',
+  gt: '>',
+  laquo: '\u00ab',
+  larr: '\u2190',
+  nbsp: '\u00a0',
+  quot: '"',
+  raquo: '\u00bb',
+  rarr: '\u2192',
+  uarr: '\u2191',
 }
 }
 
 
-export function decodeHtmlEntities(text) {
-	if (typeof document !== 'undefined') {
-		const textarea = document.createElement('textarea')
-		textarea.innerHTML = text
+export function decodeHtmlEntities (text) {
+  if (typeof document !== 'undefined') {
+    const textarea = document.createElement('textarea')
+    textarea.innerHTML = text
 
 
-		return textarea.value
-	}
+    return textarea.value
+  }
 
 
-	return text.replace(/&#x([0-9a-fA-F]+);?/g, (_, hex) => {
-		const codePoint = Number.parseInt(hex, 16)
-		return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : ''
-	}).replace(/&#(\d+);?/g, (_, decimal) => {
-		const codePoint = Number.parseInt(decimal, 10)
-		return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : ''
-	}).replace(/&([a-zA-Z][a-zA-Z0-9]+);?/g, (entity, name) => {
-		return fallbackNamedHtmlEntities[name] ?? entity
-	})
+  return text.replace(/&#x([0-9a-fA-F]+);?/g, (_, hex) => {
+    const codePoint = Number.parseInt(hex, 16)
+    return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : ''
+  }).replace(/&#(\d+);?/g, (_, decimal) => {
+    const codePoint = Number.parseInt(decimal, 10)
+    return Number.isFinite(codePoint) ? String.fromCodePoint(codePoint) : ''
+  }).replace(/&([a-zA-Z][a-zA-Z0-9]+);?/g, (entity, name) => {
+    return fallbackNamedHtmlEntities[name] ?? entity
+  })
 }
 }
 
 
-export function glyphLooksLikeHtml(text) {
-	const trimmedText = text.trim()
+export function glyphLooksLikeHtml (text) {
+  const trimmedText = text.trim()
 
 
-	return trimmedText.startsWith('<') || /<img\b/i.test(text) || /\/custom-webui\//i.test(text)
+  return trimmedText.startsWith('<') || /<img\b/i.test(text) || /\/custom-webui\//i.test(text)
 }
 }

+ 7 - 7
frontend/resources/vue/components/actionIconGlyphHelpers.test.mjs

@@ -3,18 +3,18 @@ import assert from 'node:assert/strict'
 import { decodeHtmlEntities, glyphLooksLikeHtml } from './actionIconGlyphHelpers.mjs'
 import { decodeHtmlEntities, glyphLooksLikeHtml } from './actionIconGlyphHelpers.mjs'
 
 
 test('decodeHtmlEntities decodes named entity icons as plain glyph text', () => {
 test('decodeHtmlEntities decodes named entity icons as plain glyph text', () => {
-	assert.equal(decodeHtmlEntities('&laquo;'), '\u00ab')
-	assert.equal(decodeHtmlEntities('&rarr;'), '\u2192')
-	assert.equal(decodeHtmlEntities('&laquo; next &rarr;'), '\u00ab next \u2192')
+  assert.equal(decodeHtmlEntities('&laquo;'), '\u00ab')
+  assert.equal(decodeHtmlEntities('&rarr;'), '\u2192')
+  assert.equal(decodeHtmlEntities('&laquo; next &rarr;'), '\u00ab next \u2192')
 })
 })
 
 
 test('decoded named entity icons are not treated as HTML markup', () => {
 test('decoded named entity icons are not treated as HTML markup', () => {
-	const decodedGlyph = decodeHtmlEntities('&rarr;')
+  const decodedGlyph = decodeHtmlEntities('&rarr;')
 
 
-	assert.equal(glyphLooksLikeHtml(decodedGlyph), false)
+  assert.equal(glyphLooksLikeHtml(decodedGlyph), false)
 })
 })
 
 
 test('decodeHtmlEntities keeps existing numeric entity icon support', () => {
 test('decodeHtmlEntities keeps existing numeric entity icon support', () => {
-	assert.equal(decodeHtmlEntities('&#x1f4a9;'), '\ud83d\udca9')
-	assert.equal(decodeHtmlEntities('&#128190;'), '\ud83d\udcbe')
+  assert.equal(decodeHtmlEntities('&#x1f4a9;'), '\ud83d\udca9')
+  assert.equal(decodeHtmlEntities('&#128190;'), '\ud83d\udcbe')
 })
 })

+ 53 - 0
frontend/resources/vue/i18n.js

@@ -0,0 +1,53 @@
+import { selectBrowserLocale } from './utils/localeSelection.js'
+
+const localePathPrefix = '../../../lang/generated/'
+const localeModules = import.meta.glob('../../../lang/generated/*.json', {
+  import: 'default'
+})
+
+export const availableLocales = Object.keys(localeModules)
+  .map(path => path.slice(localePathPrefix.length, -'.json'.length))
+  .sort()
+
+export function resolveBrowserLocale (browserLanguages = navigator.languages) {
+  return selectBrowserLocale(availableLocales, browserLanguages)
+}
+
+export function getSelectedLocale () {
+  const storedLanguage = localStorage.getItem('olivetin-language')
+
+  if (storedLanguage && storedLanguage !== 'auto' && availableLocales.includes(storedLanguage)) {
+    return storedLanguage
+  }
+
+  if (storedLanguage === 'auto') {
+    localStorage.removeItem('olivetin-language')
+  }
+
+  return resolveBrowserLocale()
+}
+
+export async function loadLocaleMessages (locale) {
+  const loadLocale = localeModules[`${localePathPrefix}${locale}.json`]
+
+  if (!loadLocale) {
+    throw new Error(`Unsupported locale: ${locale}`)
+  }
+
+  return loadLocale()
+}
+
+export async function loadInitialMessages (locale) {
+  const locales = locale === 'en' ? ['en'] : ['en', locale]
+  const loadedMessages = await Promise.all(locales.map(async currentLocale => {
+    return [currentLocale, await loadLocaleMessages(currentLocale)]
+  }))
+
+  return Object.fromEntries(loadedMessages)
+}
+
+export async function ensureLocaleMessages (i18n, locale) {
+  if (!i18n.availableLocales.includes(locale)) {
+    i18n.setLocaleMessage(locale, await loadLocaleMessages(locale))
+  }
+}

+ 16 - 12
frontend/resources/vue/router.js

@@ -40,8 +40,8 @@ const routes = [
     component: () => import('./views/LogsCalendarView.vue'),
     component: () => import('./views/LogsCalendarView.vue'),
     meta: {
     meta: {
       title: 'Logs Calendar',
       title: 'Logs Calendar',
-      breadcrumb: [
-        { name: 'Logs', href: '/logs' },
+      breadcrumbs: () => [
+        { name: 'Logs', to: { name: 'Logs' } },
         { name: 'Calendar' }
         { name: 'Calendar' }
       ]
       ]
     }
     }
@@ -52,8 +52,8 @@ const routes = [
     component: () => import('./views/LogsQueueView.vue'),
     component: () => import('./views/LogsQueueView.vue'),
     meta: {
     meta: {
       title: 'Execution Queue',
       title: 'Execution Queue',
-      breadcrumb: [
-        { name: 'Logs', href: '/logs' },
+      breadcrumbs: () => [
+        { name: 'Logs', to: { name: 'Logs' } },
         { name: 'Queue' }
         { name: 'Queue' }
       ]
       ]
     }
     }
@@ -74,8 +74,8 @@ const routes = [
     props: true,
     props: true,
     meta: {
     meta: {
       title: 'OliveTin - Entity Details',
       title: 'OliveTin - Entity Details',
-      breadcrumb: [
-        { name: 'Entities', href: '/entities' },
+      breadcrumbs: () => [
+        { name: 'Entities', to: { name: 'Entities' } },
         { name: 'Entity Details' }
         { name: 'Entity Details' }
       ]
       ]
     }
     }
@@ -87,8 +87,8 @@ const routes = [
     props: true,
     props: true,
     meta: {
     meta: {
       title: 'Execution',
       title: 'Execution',
-      breadcrumb: [
-        { name: 'Logs', href: '/logs' },
+      breadcrumbs: () => [
+        { name: 'Logs', to: { name: 'Logs' } },
         { name: 'Execution' }
         { name: 'Execution' }
       ]
       ]
     }
     }
@@ -100,8 +100,8 @@ const routes = [
     props: true,
     props: true,
     meta: {
     meta: {
       title: 'Action Details',
       title: 'Action Details',
-      breadcrumb: [
-        { name: 'Actions', href: '/' },
+      breadcrumbs: () => [
+        { name: 'Actions', to: { name: 'Actions' } },
         { name: 'Action Details' }
         { name: 'Action Details' }
       ]
       ]
     }
     }
@@ -113,8 +113,8 @@ const routes = [
     props: true,
     props: true,
     meta: {
     meta: {
       title: 'Execution conditions',
       title: 'Execution conditions',
-      breadcrumb: [
-        { name: 'Actions', href: '/' },
+      breadcrumbs: () => [
+        { name: 'Actions', to: { name: 'Actions' } },
         { name: 'Execution conditions' }
         { name: 'Execution conditions' }
       ]
       ]
     }
     }
@@ -171,6 +171,10 @@ router.beforeEach((to) => {
 
 
 // Navigation guard for authentication (if needed)
 // Navigation guard for authentication (if needed)
 router.beforeEach((to) => {
 router.beforeEach((to) => {
+  if (window.initResponse?.loginRequired && to.name !== 'Login') {
+    return '/login'
+  }
+
   const isAuthenticated = window.isAuthenticated ?? false
   const isAuthenticated = window.isAuthenticated ?? false
 
 
   if (to.meta.requiresAuth && !isAuthenticated) {
   if (to.meta.requiresAuth && !isAuthenticated) {

+ 1 - 1
frontend/resources/vue/utils/choiceChecklistHelpers.test.mjs

@@ -13,7 +13,7 @@ const choices = [
   { title: 'Photos', value: 'photos' }
   { title: 'Photos', value: 'photos' }
 ]
 ]
 
 
-	test('parseChecklistValue parses JSON-encoded values', () => {
+test('parseChecklistValue parses JSON-encoded values', () => {
   assert.deepEqual(parseChecklistValue('["documents","photos"]'), ['documents', 'photos'])
   assert.deepEqual(parseChecklistValue('["documents","photos"]'), ['documents', 'photos'])
   assert.deepEqual(parseChecklistValue('["kitchen,bedroom","hallway"]'), ['kitchen,bedroom', 'hallway'])
   assert.deepEqual(parseChecklistValue('["kitchen,bedroom","hallway"]'), ['kitchen,bedroom', 'hallway'])
   assert.deepEqual(parseChecklistValue(''), [])
   assert.deepEqual(parseChecklistValue(''), [])

+ 23 - 0
frontend/resources/vue/utils/localeSelection.js

@@ -0,0 +1,23 @@
+export function selectBrowserLocale (availableLocales, browserLanguages) {
+  for (const candidate of browserLanguages || []) {
+    const lowerCandidate = candidate.toLowerCase()
+    const exact = availableLocales.find(locale => locale.toLowerCase() === lowerCandidate)
+
+    if (exact) {
+      return exact
+    }
+
+    const parts = lowerCandidate.split('-')
+
+    for (let length = parts.length - 1; length > 0; length--) {
+      const prefix = `${parts.slice(0, length).join('-')}-`
+      const match = availableLocales.find(locale => locale.toLowerCase().startsWith(prefix))
+
+      if (match) {
+        return match
+      }
+    }
+  }
+
+  return 'en'
+}

+ 22 - 0
frontend/resources/vue/utils/localeSelection.test.mjs

@@ -0,0 +1,22 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import { selectBrowserLocale } from './localeSelection.js'
+
+const availableLocales = ['de-DE', 'en', 'es-ES', 'zh-Hans-CN', 'zh-Hant-TW']
+
+test('selectBrowserLocale prefers an exact locale match', () => {
+  assert.equal(selectBrowserLocale(availableLocales, ['es-ES']), 'es-ES')
+})
+
+test('selectBrowserLocale falls back to a matching language', () => {
+  assert.equal(selectBrowserLocale(availableLocales, ['de-AT']), 'de-DE')
+})
+
+test('selectBrowserLocale preserves script subtags during fallback', () => {
+  assert.equal(selectBrowserLocale(availableLocales, ['zh-Hant-HK']), 'zh-Hant-TW')
+})
+
+test('selectBrowserLocale uses English when no locale matches', () => {
+  assert.equal(selectBrowserLocale(availableLocales, ['fr-FR']), 'en')
+})

+ 18 - 18
frontend/resources/vue/utils/prefilledArguments.test.mjs

@@ -4,31 +4,31 @@ import assert from 'node:assert/strict'
 import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from './prefilledArguments.js'
 import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from './prefilledArguments.js'
 
 
 test('readPrefilledArgumentsFromNavigation returns navigation state values', () => {
 test('readPrefilledArgumentsFromNavigation returns navigation state values', () => {
-	assert.deepEqual(
-		readPrefilledArgumentsFromNavigation({ prefilledArguments: { ansible_host: '10.0.0.1' } }),
-		{ ansible_host: '10.0.0.1' }
-	)
+  assert.deepEqual(
+    readPrefilledArgumentsFromNavigation({ prefilledArguments: { ansible_host: '10.0.0.1' } }),
+    { ansible_host: '10.0.0.1' }
+  )
 })
 })
 
 
 test('readPrefilledArgumentsFromNavigation returns empty object when state is absent', () => {
 test('readPrefilledArgumentsFromNavigation returns empty object when state is absent', () => {
-	assert.deepEqual(readPrefilledArgumentsFromNavigation({}), {})
-	assert.deepEqual(readPrefilledArgumentsFromNavigation(undefined), {})
+  assert.deepEqual(readPrefilledArgumentsFromNavigation({}), {})
+  assert.deepEqual(readPrefilledArgumentsFromNavigation(undefined), {})
 })
 })
 
 
 test('getInitialArgumentValue prefers navigation state over query params', () => {
 test('getInitialArgumentValue prefers navigation state over query params', () => {
-	assert.equal(
-		getInitialArgumentValue(
-			'ansible_host',
-			{ ansible_host: '10.0.0.1' },
-			'?ansible_host=10.0.0.2'
-		),
-		'10.0.0.1'
-	)
+  assert.equal(
+    getInitialArgumentValue(
+      'ansible_host',
+      { ansible_host: '10.0.0.1' },
+      '?ansible_host=10.0.0.2'
+    ),
+    '10.0.0.1'
+  )
 })
 })
 
 
 test('getInitialArgumentValue falls back to query params when state is absent', () => {
 test('getInitialArgumentValue falls back to query params when state is absent', () => {
-	assert.equal(
-		getInitialArgumentValue('ansible_host', {}, '?ansible_host=10.0.0.2'),
-		'10.0.0.2'
-	)
+  assert.equal(
+    getInitialArgumentValue('ansible_host', {}, '?ansible_host=10.0.0.2'),
+    '10.0.0.2'
+  )
 })
 })

+ 32 - 0
frontend/resources/vue/utils/themeLoader.js

@@ -0,0 +1,32 @@
+const DEFAULT_THEME_FETCH_TIMEOUT_MS = 5000
+
+export async function applyThemeStyles (themePreference = '') {
+  let themeStyle = document.getElementById('theme-style')
+
+  if (!themeStyle) {
+    themeStyle = document.createElement('style')
+    themeStyle.id = 'theme-style'
+    themeStyle.type = 'text/css'
+    document.head.appendChild(themeStyle)
+  }
+
+  const themeUrl = themePreference && themePreference !== ''
+    ? `/custom-webui/themes/${encodeURIComponent(themePreference)}/theme.css`
+    : '/theme.css'
+
+  const response = await fetch(themeUrl, {
+    cache: 'no-store',
+    signal: AbortSignal.timeout(DEFAULT_THEME_FETCH_TIMEOUT_MS)
+  })
+  if (!response.ok) {
+    throw new Error(`theme fetch failed: ${response.status}`)
+  }
+
+  const css = await response.text()
+  themeStyle.textContent = `@layer theme { ${css} }`
+  document.body.setAttribute('loaded-theme', themeUrl)
+}
+
+export function getStoredThemePreference () {
+  return localStorage.getItem('olivetin-theme') || ''
+}

+ 57 - 0
frontend/resources/vue/utils/themeLoader.test.mjs

@@ -0,0 +1,57 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+function installThemeDom () {
+  const styleEl = { textContent: '' }
+  const body = { attributes: {}, setAttribute (name, value) { this.attributes[name] = value } }
+
+  globalThis.document = {
+    getElementById: (id) => (id === 'theme-style' ? styleEl : null),
+    createElement: () => styleEl,
+    head: { appendChild: () => {} },
+    body
+  }
+
+  return { styleEl, body }
+}
+
+test('applyThemeStyles times out when the response body stalls', async (t) => {
+  const originalFetch = globalThis.fetch
+  const originalDocument = globalThis.document
+  const originalAbortSignal = globalThis.AbortSignal
+
+  t.after(() => {
+    globalThis.fetch = originalFetch
+    globalThis.document = originalDocument
+    globalThis.AbortSignal = originalAbortSignal
+  })
+
+  installThemeDom()
+
+  globalThis.AbortSignal = {
+    timeout () {
+      const controller = new AbortController()
+      setTimeout(() => controller.abort(), 50)
+      return controller.signal
+    }
+  }
+
+  globalThis.fetch = (_url, options) => Promise.resolve({
+    ok: true,
+    status: 200,
+    text () {
+      return new Promise((_resolve, reject) => {
+        options.signal.addEventListener('abort', () => {
+          reject(new DOMException('The operation was aborted.', 'AbortError'))
+        })
+      })
+    }
+  })
+
+  const { applyThemeStyles } = await import('./themeLoader.js')
+
+  await assert.rejects(
+    () => applyThemeStyles(''),
+    (err) => err.name === 'AbortError' || err.name === 'TimeoutError'
+  )
+})

+ 42 - 139
frontend/resources/vue/views/ActionDetailsView.vue

@@ -1,5 +1,8 @@
 <template>
 <template>
-  <Section :padding="false">
+  <Section
+    :icon="PlayIcon"
+    :padding="false"
+  >
     <template #title>
     <template #title>
       <span class="section-title-with-icon">
       <span class="section-title-with-icon">
         Action Details:
         Action Details:
@@ -12,35 +15,33 @@
       </span>
       </span>
     </template>
     </template>
     <template #toolbar>
     <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>
+      <button
+        v-for="dashboard in backToDashboards"
+        :key="dashboard.path"
+        :title="'Back to ' + dashboard.title"
+        class="button neutral inline-icon"
+        @click="goToDashboard(dashboard.path)"
+      >
+        <HugeiconsIcon :icon="DashboardSquare01Icon" />
+        {{ dashboard.title }}
+      </button>
+      <button
+        v-if="action"
+        title="Run this action"
+        class="button neutral inline-icon"
+        @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>
     </template>
     </template>
 
 
     <div
     <div
@@ -129,67 +130,20 @@
     </div>
     </div>
 
 
     <div v-show="filteredLogs.length > 0">
     <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>
+      <ExecutionLogsTable
+        variant="action-history"
+        :logs="filteredLogs"
+        :loading="loading"
+      >
+        <template #duration="{ row }">
+          {{ formatExecutionDuration(row) }}
+        </template>
+      </ExecutionLogsTable>
 
 
       <Pagination
       <Pagination
         :page-size="pageSize"
         :page-size="pageSize"
         :total="totalCount"
         :total="totalCount"
         :current-page="currentPage"
         :current-page="currentPage"
-        :page="currentPage"
         class="padding"
         class="padding"
         item-title="execution logs"
         item-title="execution logs"
         @page-change="handlePageChange"
         @page-change="handlePageChange"
@@ -215,11 +169,10 @@ import { useRoute, useRouter } from 'vue-router'
 import Pagination from 'picocrank/vue/components/Pagination.vue'
 import Pagination from 'picocrank/vue/components/Pagination.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
-import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
 import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
 import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
-import LogActionTitle from '../components/LogActionTitle.vue'
+import ExecutionLogsTable from '../components/ExecutionLogsTable.vue'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { HugeiconsIcon } from '@hugeicons/vue'
-import { DashboardSquare01Icon, WorkoutRunIcon } from '@hugeicons/core-free-icons'
+import { DashboardSquare01Icon, PlayIcon, WorkoutRunIcon } from '@hugeicons/core-free-icons'
 import { requestReconnectNow } from '../../../js/websocket.js'
 import { requestReconnectNow } from '../../../js/websocket.js'
 import { needsArgumentForm } from '../utils/needsArgumentForm.js'
 import { needsArgumentForm } from '../utils/needsArgumentForm.js'
 import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
 import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
@@ -320,16 +273,6 @@ function clearSearch () {
   searchText.value = ''
   searchText.value = ''
 }
 }
 
 
-function formatTimestamp (timestamp) {
-  if (!timestamp) return 'Unknown'
-  try {
-    const date = new Date(timestamp)
-    return date.toLocaleString()
-  } catch (err) {
-    return timestamp
-  }
-}
-
 function plural (n, singular, pluralForm) {
 function plural (n, singular, pluralForm) {
   return n === 1 ? `1 ${singular}` : `${n} ${pluralForm}`
   return n === 1 ? `1 ${singular}` : `${n} ${pluralForm}`
 }
 }
@@ -483,39 +426,6 @@ onUnmounted(() => {
   font-size: 1.5rem;
   font-size: 1.5rem;
 }
 }
 
 
-.logs-table {
-  width: 100%;
-  border-collapse: collapse;
-}
-
-.logs-table th {
-  background-color: var(--section-background);
-  padding: 0.5rem;
-  text-align: left;
-  font-weight: 600;
-}
-
-.logs-table td {
-  padding: 0.5rem;
-  border-top: 1px solid var(--border-color);
-}
-
-.log-row:hover {
-  background-color: var(--hover-background);
-}
-
-.timestamp {
-  font-family: monospace;
-  font-size: 0.9rem;
-  color: var(--text-secondary);
-}
-
-.duration {
-  font-size: 0.9rem;
-  color: var(--text-secondary);
-  white-space: nowrap;
-}
-
 .empty-state {
 .empty-state {
   padding: 2rem;
   padding: 2rem;
   text-align: center;
   text-align: center;
@@ -599,13 +509,6 @@ onUnmounted(() => {
   padding: 1rem;
   padding: 1rem;
 }
 }
 
 
-.action-details-toolbar {
-  display: inline-flex;
-  flex-wrap: wrap;
-  gap: 0.5rem;
-  align-items: center;
-}
-
 .action-group-list {
 .action-group-list {
   margin: 0;
   margin: 0;
   padding-left: 0;
   padding-left: 0;

+ 2 - 0
frontend/resources/vue/views/ActionExecConditionsView.vue

@@ -1,6 +1,7 @@
 <template>
 <template>
   <Section
   <Section
     :title="'Execution conditions: ' + actionTitle"
     :title="'Execution conditions: ' + actionTitle"
+    :icon="TimeScheduleIcon"
     :padding="false"
     :padding="false"
   >
   >
     <template #toolbar>
     <template #toolbar>
@@ -168,6 +169,7 @@
 <script setup>
 <script setup>
 import { ref, computed, onMounted, watch } from 'vue'
 import { ref, computed, onMounted, watch } from 'vue'
 import { useRoute } from 'vue-router'
 import { useRoute } from 'vue-router'
+import { TimeScheduleIcon } from '@hugeicons/core-free-icons'
 import Section from 'picocrank/vue/components/Section.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 
 
 const route = useRoute()
 const route = useRoute()

+ 24 - 6
frontend/resources/vue/views/ArgumentForm.vue

@@ -4,12 +4,18 @@
       <h2>
       <h2>
         <span class="section-title-with-icon">
         <span class="section-title-with-icon">
           Start action:
           Start action:
-          <ActionIconGlyph
-            v-if="icon"
-            class="action-title-icon"
-            :glyph="icon"
-          />
-          {{ title }}
+          <router-link
+            :to="`/action/${bindingId}`"
+            class="action-details-title-link"
+            :title="'Action ID: ' + bindingId"
+          >
+            <ActionIconGlyph
+              v-if="icon"
+              class="action-title-icon"
+              :glyph="icon"
+            />
+            {{ title }}
+          </router-link>
         </span>
         </span>
       </h2>
       </h2>
     </div>
     </div>
@@ -681,6 +687,18 @@ onUnmounted(() => {
   font-size: 1.5rem;
   font-size: 1.5rem;
 }
 }
 
 
+.action-details-title-link {
+  display: inline-flex;
+  align-items: center;
+  gap: 0.5rem;
+  color: inherit;
+  text-decoration: none;
+}
+
+.action-details-title-link:hover {
+  text-decoration: underline;
+}
+
 form {
 form {
   grid-template-columns: max-content auto auto;
   grid-template-columns: max-content auto auto;
 }
 }

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

@@ -1,5 +1,8 @@
 <template>
 <template>
-  <Section :title="t('diagnostics.config-issues')">
+  <Section
+    :title="t('diagnostics.config-issues')"
+    :icon="AlertCircleIcon"
+  >
     <p>{{ t('diagnostics.config-issues-description') }}</p>
     <p>{{ t('diagnostics.config-issues-description') }}</p>
 
 
     <p v-if="!loading && configIssues.length === 0">
     <p v-if="!loading && configIssues.length === 0">
@@ -13,17 +16,18 @@
       :show-pagination="false"
       :show-pagination="false"
     >
     >
       <template #cell-severity="{ value }">
       <template #cell-severity="{ value }">
-        <div
+        <span
           class="tag"
           class="tag"
-          :class="value === 'error' ? 'fg-bad' : 'fg-warning'"
-        >
-          {{ value }}
-        </div>
+          :class="value"
+        >{{ value }}</span>
       </template>
       </template>
     </Table>
     </Table>
   </Section>
   </Section>
 
 
-  <Section :title="t('diagnostics.get-support')">
+  <Section
+    :title="t('diagnostics.get-support')"
+    :icon="CustomerSupportIcon"
+  >
     <p>
     <p>
       {{ t('diagnostics.get-support-description') }}
       {{ t('diagnostics.get-support-description') }}
     </p>
     </p>
@@ -37,7 +41,10 @@
     </ul>
     </ul>
   </Section>
   </Section>
 
 
-  <Section :title="t('diagnostics.ssh')">
+  <Section
+    :title="t('diagnostics.ssh')"
+    :icon="ComputerTerminal01Icon"
+  >
     <dl>
     <dl>
       <dt>{{ t('diagnostics.found-key') }}</dt>
       <dt>{{ t('diagnostics.found-key') }}</dt>
       <dd>{{ diagnostics.sshFoundKey || '?' }}</dd>
       <dd>{{ diagnostics.sshFoundKey || '?' }}</dd>
@@ -46,7 +53,10 @@
     </dl>
     </dl>
   </Section>
   </Section>
 
 
-  <Section :title="t('diagnostics.server-diagnostics')">
+  <Section
+    :title="t('diagnostics.server-diagnostics')"
+    :icon="CloudServerIcon"
+  >
     <p>{{ t('diagnostics.server-diagnostics-description') }}</p>
     <p>{{ t('diagnostics.server-diagnostics-description') }}</p>
     <p>
     <p>
       <a
       <a
@@ -55,60 +65,63 @@
       >{{ t('diagnostics.server-diagnostics-docs') }}</a>
       >{{ t('diagnostics.server-diagnostics-docs') }}</a>
     </p>
     </p>
 
 
-    <div role="toolbar">
-      <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
+    <ReadOnlyTextArea
       v-model="serverDiagnostics"
       v-model="serverDiagnostics"
-      readonly
-      style="flex: 1; min-height: 200px; resize: vertical; width: 100%; box-sizing: border-box;"
-    />
+      :rows="10"
+      markdown-ticks
+      :copy-label="t('diagnostics.copy-to-clipboard')"
+      :copied-label="t('diagnostics.copied')"
+    >
+      <template #actions>
+        <button
+          type="button"
+          :disabled="loading"
+          class="good"
+          @click="generateServerDiagnostics"
+        >
+          {{ t('diagnostics.generate-server-diagnostics') }}
+        </button>
+      </template>
+    </ReadOnlyTextArea>
   </Section>
   </Section>
 
 
-  <Section :title="t('diagnostics.browser-info')">
+  <Section
+    :title="t('diagnostics.browser-info')"
+    :icon="BrowserIcon"
+  >
     <p>{{ t('diagnostics.browser-info-description') }}</p>
     <p>{{ t('diagnostics.browser-info-description') }}</p>
 
 
-    <div role="toolbar">
-      <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
+    <ReadOnlyTextArea
       v-model="browserInfo"
       v-model="browserInfo"
-      readonly
-      style="flex: 1; min-height: 200px; resize: vertical; width: 100%; box-sizing: border-box;"
-    />
+      :rows="10"
+      markdown-ticks
+      :copy-label="t('diagnostics.copy-to-clipboard')"
+      :copied-label="t('diagnostics.copied')"
+    >
+      <template #actions>
+        <button
+          type="button"
+          :disabled="loading"
+          class="good"
+          @click="generateBrowserInfo"
+        >
+          {{ t('diagnostics.generate-browser-info') }}
+        </button>
+      </template>
+    </ReadOnlyTextArea>
   </Section>
   </Section>
 </template>
 </template>
 
 
 <script setup>
 <script setup>
 import { ref, computed, onMounted, onUnmounted } from 'vue'
 import { ref, computed, onMounted, onUnmounted } from 'vue'
+import {
+  AlertCircleIcon,
+  BrowserIcon,
+  CloudServerIcon,
+  ComputerTerminal01Icon,
+  CustomerSupportIcon
+} from '@hugeicons/core-free-icons'
+import ReadOnlyTextArea from 'picocrank/vue/components/ReadOnlyTextArea.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import Table from 'picocrank/vue/components/Table.vue'
 import Table from 'picocrank/vue/components/Table.vue'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
@@ -120,8 +133,6 @@ const configIssues = ref([])
 const loading = ref(false)
 const loading = ref(false)
 const serverDiagnostics = ref('')
 const serverDiagnostics = ref('')
 const browserInfo = ref('')
 const browserInfo = ref('')
-const serverDiagnosticsCopied = ref(false)
-const browserInfoCopied = ref(false)
 
 
 const configIssueHeaders = computed(() => [
 const configIssueHeaders = computed(() => [
   { key: 'severity', label: t('diagnostics.config-issue-severity'), sortable: true, width: '7rem' },
   { key: 'severity', label: t('diagnostics.config-issue-severity'), sortable: true, width: '7rem' },
@@ -170,7 +181,7 @@ async function generateServerDiagnostics () {
   try {
   try {
     const response = await window.client.serverDiagnostics()
     const response = await window.client.serverDiagnostics()
     console.log('response', response)
     console.log('response', response)
-    serverDiagnostics.value = `\`\`\`\n${response.alert}\n\`\`\`\n`
+    serverDiagnostics.value = response.alert
   } catch (err) {
   } catch (err) {
     console.error('Failed to generate server diagnostics:', err)
     console.error('Failed to generate server diagnostics:', err)
     serverDiagnostics.value = ''
     serverDiagnostics.value = ''
@@ -179,18 +190,6 @@ async function generateServerDiagnostics () {
   }
   }
 }
 }
 
 
-async function copyServerDiagnostics () {
-  try {
-    await navigator.clipboard.writeText(serverDiagnostics.value)
-    serverDiagnosticsCopied.value = true
-    setTimeout(() => {
-      serverDiagnosticsCopied.value = false
-    }, 2000)
-  } catch (err) {
-    console.error('Failed to copy Server Diagnostics to clipboard:', err)
-  }
-}
-
 async function generateBrowserInfo () {
 async function generateBrowserInfo () {
   loading.value = true
   loading.value = true
   try {
   try {
@@ -258,7 +257,6 @@ async function generateBrowserInfo () {
     const currentLanguage = locale.value || t('diagnostics.unknown')
     const currentLanguage = locale.value || t('diagnostics.unknown')
 
 
     let output = ''
     let output = ''
-    output += '```\n'
     output += '### BROWSER INFO START (copy all text to BROWSER INFO END)\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 += `olivetinVersion: ${olivetinVersion}\n`
@@ -289,7 +287,6 @@ async function generateBrowserInfo () {
     output += `timezone: ${info.timezone}\n`
     output += `timezone: ${info.timezone}\n`
     output += `timezoneOffset: ${info.timezoneOffset}\n`
     output += `timezoneOffset: ${info.timezoneOffset}\n`
     output += '\n### BROWSER INFO END (copy all text from BROWSER INFO START)'
     output += '\n### BROWSER INFO END (copy all text from BROWSER INFO START)'
-    output += '\n```\n'
 
 
     browserInfo.value = output
     browserInfo.value = output
   } finally {
   } finally {
@@ -297,18 +294,6 @@ async function generateBrowserInfo () {
   }
   }
 }
 }
 
 
-async function copyBrowserInfo () {
-  try {
-    await navigator.clipboard.writeText(browserInfo.value)
-    browserInfoCopied.value = true
-    setTimeout(() => {
-      browserInfoCopied.value = false
-    }, 2000)
-  } catch (err) {
-    console.error('Failed to copy browser info to clipboard:', err)
-  }
-}
-
 onMounted(() => {
 onMounted(() => {
   fetchDiagnostics()
   fetchDiagnostics()
   window.addEventListener('EventConfigChanged', fetchDiagnostics)
   window.addEventListener('EventConfigChanged', fetchDiagnostics)

+ 19 - 3
frontend/resources/vue/views/EntitiesView.vue

@@ -2,10 +2,12 @@
   <Section
   <Section
     v-if="!definitionsLoaded"
     v-if="!definitionsLoaded"
     title="Loading entity definitions..."
     title="Loading entity definitions..."
+    :icon="CellsIcon"
   />
   />
   <Section
   <Section
     v-else-if="totalInstances === 0"
     v-else-if="totalInstances === 0"
     title="There are no entities to show yet."
     title="There are no entities to show yet."
+    :icon="CellsIcon"
   >
   >
     <p>
     <p>
       When OliveTin has registered entity instances (for example from entity files or your setup), they will be listed here.
       When OliveTin has registered entity instances (for example from entity files or your setup), they will be listed here.
@@ -21,11 +23,13 @@
 </template>
 </template>
 
 
 <script setup>
 <script setup>
-import { ref, computed, onMounted } from 'vue'
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import { CellsIcon } from '@hugeicons/core-free-icons'
 import Section from 'picocrank/vue/components/Section.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
 import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
 const definitionsLoaded = ref(false)
 const definitionsLoaded = ref(false)
 const entityDefinitions = ref([])
 const entityDefinitions = ref([])
+let entityFetchGeneration = 0
 
 
 const totalInstances = computed(() =>
 const totalInstances = computed(() =>
   entityDefinitions.value.reduce(
   entityDefinitions.value.reduce(
@@ -35,19 +39,31 @@ const totalInstances = computed(() =>
 )
 )
 
 
 async function fetchEntities () {
 async function fetchEntities () {
+  const fetchGeneration = ++entityFetchGeneration
   try {
   try {
     const ret = await window.client.getEntities()
     const ret = await window.client.getEntities()
+    if (fetchGeneration !== entityFetchGeneration) return
     entityDefinitions.value = ret.entityDefinitions ?? []
     entityDefinitions.value = ret.entityDefinitions ?? []
   } catch (err) {
   } catch (err) {
+    if (fetchGeneration !== entityFetchGeneration) return
     console.error('Failed to fetch entities:', err)
     console.error('Failed to fetch entities:', err)
     window.showBigError('fetch-entities', 'getting entities', err, false)
     window.showBigError('fetch-entities', 'getting entities', err, false)
     entityDefinitions.value = []
     entityDefinitions.value = []
   } finally {
   } finally {
-    definitionsLoaded.value = true
+    if (fetchGeneration === entityFetchGeneration) {
+      definitionsLoaded.value = true
+    }
   }
   }
 }
 }
 
 
 onMounted(() => {
 onMounted(() => {
-	    fetchEntities()
+  fetchEntities()
+  window.addEventListener('EventEntityChanged', fetchEntities)
+  window.addEventListener('EventConfigChanged', fetchEntities)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('EventEntityChanged', fetchEntities)
+  window.removeEventListener('EventConfigChanged', fetchEntities)
 })
 })
 </script>
 </script>

+ 32 - 41
frontend/resources/vue/views/EntityDetailsView.vue

@@ -1,5 +1,5 @@
 <template>
 <template>
-  <Section>
+  <Section :icon="CellsIcon">
     <template #title>
     <template #title>
       <span class="section-title-with-icon">
       <span class="section-title-with-icon">
         Entity Details:
         Entity Details:
@@ -13,7 +13,9 @@
     </template>
     </template>
     <template #toolbar>
     <template #toolbar>
       <button
       <button
-        class="back-button"
+        type="button"
+        class="button neutral inline-icon"
+        title="Back"
         @click="goBack"
         @click="goBack"
       >
       >
         <HugeiconsIcon
         <HugeiconsIcon
@@ -21,10 +23,13 @@
           width="1.2em"
           width="1.2em"
           height="1.2em"
           height="1.2em"
         />
         />
-        <span>Back</span>
+        Back
       </button>
       </button>
     </template>
     </template>
-    <div v-if="!entityDetails">
+    <div v-if="entityNotFound">
+      <p>Entity not found.</p>
+    </div>
+    <div v-else-if="!entityDetails">
       <p>Loading entity details...</p>
       <p>Loading entity details...</p>
     </div>
     </div>
     <template v-else>
     <template v-else>
@@ -38,12 +43,6 @@
             {{ entityType }}
             {{ entityType }}
           </router-link>
           </router-link>
         </dd>
         </dd>
-        <dt v-if="entityDetails.title">
-          Title
-        </dt>
-        <dd v-if="entityDetails.title">
-          {{ entityDetails.title }}
-        </dd>
         <template v-if="entityDetails.fields">
         <template v-if="entityDetails.fields">
           <template
           <template
             v-for="(value, key) in entityDetails.fields"
             v-for="(value, key) in entityDetails.fields"
@@ -63,6 +62,7 @@
   <Section
   <Section
     v-if="entityDetails"
     v-if="entityDetails"
     title="Dashboard Entity Directories"
     title="Dashboard Entity Directories"
+    :icon="Folder01Icon"
   >
   >
     <div
     <div
       v-if="filteredDirectories.length > 0"
       v-if="filteredDirectories.length > 0"
@@ -123,16 +123,17 @@
 </template>
 </template>
 
 
 <script setup>
 <script setup>
-import { ref, computed, onMounted } from 'vue'
+import { ref, computed, onMounted, onUnmounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { useRouter } from 'vue-router'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { HugeiconsIcon } from '@hugeicons/vue'
-import { ArrowLeftIcon } from '@hugeicons/core-free-icons'
+import { ArrowLeftIcon, CellsIcon, Folder01Icon } from '@hugeicons/core-free-icons'
 import Section from 'picocrank/vue/components/Section.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import ActionButton from '../ActionButton.vue'
 import ActionButton from '../ActionButton.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 
 
 const router = useRouter()
 const router = useRouter()
 const entityDetails = ref(null)
 const entityDetails = ref(null)
+const entityNotFound = ref(false)
 
 
 const props = defineProps({
 const props = defineProps({
   entityType: String,
   entityType: String,
@@ -154,6 +155,10 @@ function goBack () {
   router.push({ name: 'Entities' })
   router.push({ name: 'Entities' })
 }
 }
 
 
+function isEntityNotFoundError (err) {
+  return err.status === 404 || err.code === 'NotFound' || err.message?.includes('not found')
+}
+
 async function fetchEntityDetails () {
 async function fetchEntityDetails () {
   try {
   try {
     const response = await window.client.getEntity({
     const response = await window.client.getEntity({
@@ -162,38 +167,33 @@ async function fetchEntityDetails () {
     })
     })
 
 
     entityDetails.value = response
     entityDetails.value = response
+    entityNotFound.value = false
   } catch (err) {
   } catch (err) {
+    if (isEntityNotFoundError(err)) {
+      entityDetails.value = null
+      entityNotFound.value = true
+      return
+    }
+
     console.error('Failed to fetch entity details:', err)
     console.error('Failed to fetch entity details:', err)
     window.showBigError('fetch-entity-details', 'getting entity details', err, false)
     window.showBigError('fetch-entity-details', 'getting entity details', err, false)
   }
   }
 }
 }
 
 
 onMounted(() => {
 onMounted(() => {
-	    fetchEntityDetails()
+  fetchEntityDetails()
+  window.addEventListener('EventEntityChanged', fetchEntityDetails)
+  window.addEventListener('EventConfigChanged', fetchEntityDetails)
+})
+
+onUnmounted(() => {
+  window.removeEventListener('EventEntityChanged', fetchEntityDetails)
+  window.removeEventListener('EventConfigChanged', fetchEntityDetails)
 })
 })
 
 
 </script>
 </script>
 
 
 <style scoped>
 <style scoped>
-.back-button {
-    display: flex;
-    align-items: center;
-    gap: 0.5em;
-    padding: 0.5em 1em;
-    background-color: var(--bg, #fff);
-    border: 1px solid var(--border-color, #ccc);
-    border-radius: 0.5em;
-    cursor: pointer;
-    font-size: 0.9em;
-    box-shadow: 0 0 .3em rgba(0, 0, 0, 0.1);
-    transition: background-color 0.2s, box-shadow 0.2s;
-}
-
-.back-button:hover {
-    background-color: var(--bg-hover, #f5f5f5);
-    box-shadow: 0 0 .5em rgba(0, 0, 0, 0.15);
-}
-
 .directory-list a {
 .directory-list a {
     text-decoration: none;
     text-decoration: none;
     padding: 0.5em;
     padding: 0.5em;
@@ -236,15 +236,6 @@ fieldset {
 }
 }
 
 
 @media (prefers-color-scheme: dark) {
 @media (prefers-color-scheme: dark) {
-    .back-button {
-        background-color: var(--bg, #111);
-        border-color: var(--border-color, #333);
-    }
-
-    .back-button:hover {
-        background-color: var(--bg-hover, #222);
-    }
-
     .directory-list a:hover {
     .directory-list a:hover {
         background-color: var(--bg-hover, #222);
         background-color: var(--bg-hover, #222);
     }
     }

+ 40 - 16
frontend/resources/vue/views/ExecutionView.vue

@@ -1,5 +1,8 @@
 <template>
 <template>
-  <Section id="execution-results-popup">
+  <Section
+    id="execution-results-popup"
+    :icon="ComputerTerminal01Icon"
+  >
     <template #title>
     <template #title>
       <span class="section-title-with-icon">
       <span class="section-title-with-icon">
         Execution Results:
         Execution Results:
@@ -35,7 +38,7 @@
         v-for="dashboard in backToDashboards"
         v-for="dashboard in backToDashboards"
         :key="dashboard.path"
         :key="dashboard.path"
         :title="'Back to ' + dashboard.title"
         :title="'Back to ' + dashboard.title"
-        class="button neutral"
+        class="button neutral inline-icon"
         @click="goToDashboard(dashboard.path)"
         @click="goToDashboard(dashboard.path)"
       >
       >
         <HugeiconsIcon :icon="DashboardSquare01Icon" />
         <HugeiconsIcon :icon="DashboardSquare01Icon" />
@@ -44,7 +47,7 @@
       <button
       <button
         v-if="backToDashboards.length === 0"
         v-if="backToDashboards.length === 0"
         title="Go back"
         title="Go back"
-        class="button neutral"
+        class="button neutral inline-icon"
         @click="goBack"
         @click="goBack"
       >
       >
         <HugeiconsIcon :icon="ArrowLeftIcon" />
         <HugeiconsIcon :icon="ArrowLeftIcon" />
@@ -122,6 +125,8 @@
       <div class="fg1" />
       <div class="fg1" />
 
 
       <button
       <button
+        type="button"
+        class="inline-icon"
         :disabled="!canRerun"
         :disabled="!canRerun"
         title="Rerun"
         title="Rerun"
         @click="rerunAction"
         @click="rerunAction"
@@ -131,6 +136,8 @@
       </button>
       </button>
       <button
       <button
         id="execution-dialog-kill-action"
         id="execution-dialog-kill-action"
+        type="button"
+        class="inline-icon"
         :disabled="!canKill"
         :disabled="!canKill"
         title="Kill"
         title="Kill"
         @click="killAction"
         @click="killAction"
@@ -150,7 +157,7 @@ import LogActionTitle from '../components/LogActionTitle.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import { OutputTerminal } from '../../../js/OutputTerminal.js'
 import { OutputTerminal } from '../../../js/OutputTerminal.js'
 import { HugeiconsIcon } from '@hugeicons/vue'
 import { HugeiconsIcon } from '@hugeicons/vue'
-import { WorkoutRunIcon, Cancel02Icon, ArrowLeftIcon, DashboardSquare01Icon, Copy01Icon } from '@hugeicons/core-free-icons'
+import { WorkoutRunIcon, Cancel02Icon, ArrowLeftIcon, DashboardSquare01Icon, Copy01Icon, ComputerTerminal01Icon } from '@hugeicons/core-free-icons'
 import { useRouter } from 'vue-router'
 import { useRouter } from 'vue-router'
 import { buttonResults } from '../stores/buttonResults'
 import { buttonResults } from '../stores/buttonResults'
 import { requestReconnectNow } from '../../../js/websocket.js'
 import { requestReconnectNow } from '../../../js/websocket.js'
@@ -348,15 +355,29 @@ async function fetchExecutionResult (executionTrackingIdParam) {
   } catch (err) {
   } catch (err) {
     // Check if it's a "not found" error (404 or similar)
     // Check if it's a "not found" error (404 or similar)
     if (err.status === 404 || err.code === 'NotFound' || err.message?.includes('not found')) {
     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.'
+      notFound.value = true
+      errorMessage.value = err.message || 'The execution could not be found in the system.'
     } else {
     } else {
-	  renderError(err)
+      renderError(err)
     }
     }
     throw err
     throw err
   }
   }
 }
 }
 
 
+function formatDurationRange (datetimeStarted, datetimeFinished) {
+  const startDay = datetimeStarted?.slice(0, 10)
+  const finishDay = datetimeFinished?.slice(0, 10)
+  const finishTime = datetimeFinished?.includes(' ')
+    ? datetimeFinished.slice(datetimeFinished.indexOf(' ') + 1)
+    : datetimeFinished
+
+  if (startDay && finishDay && startDay === finishDay) {
+    return `${datetimeStarted} → ${finishTime}`
+  }
+
+  return `${datetimeStarted} → ${datetimeFinished}`
+}
+
 function updateDuration (logEntryParam) {
 function updateDuration (logEntryParam) {
   logEntry.value = logEntryParam
   logEntry.value = logEntryParam
   if (logEntry.value == null) {
   if (logEntry.value == null) {
@@ -368,14 +389,17 @@ function updateDuration (logEntryParam) {
   } else {
   } else {
     let delta = ''
     let delta = ''
     try {
     try {
-		  delta = (new Date(logEntry.value.datetimeFinished) - new Date(logEntry.value.datetimeStarted)) / 1000
-	  delta = new Intl.RelativeTimeFormat().format(delta, 'seconds').replace('in ', '').replace('ago', '')
+      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)
+      console.warn('Failed to calculate delta', e)
     }
     }
-    duration.value = logEntry.value.datetimeStarted + ' → ' + logEntry.value.datetimeFinished
+    duration.value = formatDurationRange(
+      logEntry.value.datetimeStarted,
+      logEntry.value.datetimeFinished
+    )
     if (delta !== '') {
     if (delta !== '') {
-	  duration.value += ' (' + delta + ')'
+      duration.value += ' (' + delta + ')'
     }
     }
   }
   }
 }
 }
@@ -410,7 +434,7 @@ async function renderExecutionResult (res) {
   if (terminal) {
   if (terminal) {
     await terminal.reset()
     await terminal.reset()
     await terminal.write(res.logEntry.output, () => {
     await terminal.write(res.logEntry.output, () => {
-	  terminal.fit()
+      terminal.fit()
     })
     })
   }
   }
 }
 }
@@ -464,11 +488,11 @@ onMounted(() => {
   watch(
   watch(
     () => buttonResults[props.executionTrackingId],
     () => buttonResults[props.executionTrackingId],
     (newResult, oldResult) => {
     (newResult, oldResult) => {
-	  if (newResult) {
+      if (newResult) {
         renderExecutionResult({
         renderExecutionResult({
-		  logEntry: newResult
+          logEntry: newResult
         })
         })
-	  }
+      }
     }
     }
   )
   )
 })
 })

+ 2 - 0
frontend/resources/vue/views/LoginView.vue

@@ -1,6 +1,7 @@
 <template>
 <template>
   <Section
   <Section
     title="Login to OliveTin"
     title="Login to OliveTin"
+    :icon="Login01Icon"
     class="small"
     class="small"
   >
   >
     <div class="login-form">
     <div class="login-form">
@@ -89,6 +90,7 @@
 <script setup>
 <script setup>
 import { ref, onMounted, watch } from 'vue'
 import { ref, onMounted, watch } from 'vue'
 import { useRouter } from 'vue-router'
 import { useRouter } from 'vue-router'
+import { Login01Icon } from '@hugeicons/core-free-icons'
 import Section from 'picocrank/vue/components/Section.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 
 
 const router = useRouter()
 const router = useRouter()

+ 3 - 13
frontend/resources/vue/views/LogsCalendarView.vue

@@ -1,12 +1,13 @@
 <template>
 <template>
   <Section
   <Section
     :title="t('logs.calendar-title')"
     :title="t('logs.calendar-title')"
+    :icon="Calendar01Icon"
     :padding="false"
     :padding="false"
   >
   >
     <template #toolbar>
     <template #toolbar>
       <router-link
       <router-link
         :to="logsListLocation"
         :to="logsListLocation"
-        class="button neutral"
+        class="button neutral inline-icon"
       >
       >
         <svg
         <svg
           xmlns="http://www.w3.org/2000/svg"
           xmlns="http://www.w3.org/2000/svg"
@@ -42,6 +43,7 @@
 import { ref, computed, onMounted } from 'vue'
 import { ref, computed, onMounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { useRouter } from 'vue-router'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
+import { Calendar01Icon } from '@hugeicons/core-free-icons'
 import Calendar from 'picocrank/vue/components/Calendar.vue'
 import Calendar from 'picocrank/vue/components/Calendar.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import { loadStoredLogsFilter } from '../utils/logsFilterStorage.js'
 import { loadStoredLogsFilter } from '../utils/logsFilterStorage.js'
@@ -177,16 +179,4 @@ onMounted(() => {
 .padding {
 .padding {
   padding: 1rem;
   padding: 1rem;
 }
 }
-
-@media (prefers-color-scheme: dark) {
-  :deep(div.calendar-header-nav) {
-    background-color: var(--bg, #111);
-    color: var(--text-color, #fff);
-    border-color: var(--border-color, #333);
-  }
-
-  :deep(div.calendar-header-nav h2.calendar-title) {
-    color: #fff !important;
-  }
-}
 </style>
 </style>

+ 44 - 153
frontend/resources/vue/views/LogsListView.vue

@@ -1,19 +1,22 @@
 <template>
 <template>
   <Section
   <Section
     :title="t('logs.title')"
     :title="t('logs.title')"
+    :icon="LeftToRightListDashIcon"
     :padding="false"
     :padding="false"
   >
   >
     <template #toolbar>
     <template #toolbar>
       <router-link
       <router-link
         to="/logs/queue"
         to="/logs/queue"
-        class="button neutral"
+        class="button neutral inline-icon"
       >
       >
+        <HugeiconsIcon :icon="Queue01Icon" />
         {{ t('logs.queue') }}
         {{ t('logs.queue') }}
       </router-link>
       </router-link>
       <router-link
       <router-link
         to="/logs/calendar"
         to="/logs/calendar"
-        class="button neutral"
+        class="button neutral inline-icon"
       >
       >
+        <HugeiconsIcon :icon="Calendar01Icon" />
         {{ t('logs.calendar') }}
         {{ t('logs.calendar') }}
       </router-link>
       </router-link>
       <label class="input-with-icons">
       <label class="input-with-icons">
@@ -76,95 +79,44 @@
       >
       >
         {{ filterError }}
         {{ filterError }}
       </p>
       </p>
+      <p
+        v-if="selectedDate"
+        class="date-filter-banner"
+      >
+        <span>{{ formatDateFilter(selectedDate) }}</span>
+        <button
+          type="button"
+          class="button neutral inline-icon"
+          :title="t('logs.clear-date-filter')"
+          @click="clearDateFilter"
+        >
+          <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>
+          {{ t('logs.clear-date-filter') }}
+        </button>
+      </p>
     </div>
     </div>
 
 
     <div v-show="logs.length > 0">
     <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>
+      <ExecutionLogsTable
+        :logs="logs"
+        :loading="loading"
+      />
 
 
       <Pagination
       <Pagination
         :page-size="pageSize"
         :page-size="pageSize"
         :total="totalCount"
         :total="totalCount"
         :current-page="currentPage"
         :current-page="currentPage"
-        :page="currentPage"
         class="padding"
         class="padding"
         item-title="execution logs"
         item-title="execution logs"
         @page-change="handlePageChange"
         @page-change="handlePageChange"
@@ -217,9 +169,9 @@ import { ConnectError, Code } from '@connectrpc/connect'
 import Pagination from 'picocrank/vue/components/Pagination.vue'
 import Pagination from 'picocrank/vue/components/Pagination.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
-import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
-import ActionIconGlyph from '../components/ActionIconGlyph.vue'
-import LogActionTitle from '../components/LogActionTitle.vue'
+import { HugeiconsIcon } from '@hugeicons/vue'
+import { Calendar01Icon, LeftToRightListDashIcon, Queue01Icon } from '@hugeicons/core-free-icons'
+import ExecutionLogsTable from '../components/ExecutionLogsTable.vue'
 import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
 import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
 import { loadStoredLogsFilter, storeLogsFilter } from '../utils/logsFilterStorage.js'
 import { loadStoredLogsFilter, storeLogsFilter } from '../utils/logsFilterStorage.js'
 const route = useRoute()
 const route = useRoute()
@@ -368,16 +320,6 @@ function formatDateFilter (dateString) {
   }
   }
 }
 }
 
 
-function formatTimestamp (timestamp) {
-  if (!timestamp) return 'Unknown'
-  try {
-    const date = new Date(timestamp)
-    return date.toLocaleString()
-  } catch (err) {
-    return timestamp
-  }
-}
-
 function handlePageChange (page) {
 function handlePageChange (page) {
   currentPage.value = page
   currentPage.value = page
   fetchLogs()
   fetchLogs()
@@ -475,20 +417,12 @@ onUnmounted(() => {
   cursor: not-allowed;
   cursor: not-allowed;
 }
 }
 
 
-.timestamp {
-  font-family: monospace;
-  font-size: 0.875rem;
-  color: #666;
-}
-
-.icon {
-  margin-right: 0.5rem;
-  font-size: 1.2em;
-}
-
-.annotation {
-  font-weight: 500;
-  font-size: smaller;
+.date-filter-banner {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  gap: 0.75rem;
+  margin: 0;
 }
 }
 
 
 .empty-state {
 .empty-state {
@@ -506,47 +440,4 @@ onUnmounted(() => {
   text-decoration: underline;
   text-decoration: underline;
 }
 }
 
 
-.timestamp-header {
-  display: flex;
-  flex-direction: column;
-  gap: 0.25rem;
-}
-
-.date-filter-indicator {
-  display: flex;
-  align-items: center;
-  gap: 0.25rem;
-  font-size: 0.75rem;
-  font-weight: normal;
-  color: var(--text-secondary, #666);
-  white-space: nowrap;
-}
-
-.date-filter-text {
-  font-style: italic;
-}
-
-.timestamp-header .clear-date-button {
-  background: none;
-  border: none;
-  cursor: pointer;
-  padding: 0.125rem;
-  border-radius: 3px;
-  display: flex;
-  align-items: center;
-  flex-shrink: 0;
-  opacity: 0.7;
-  transition: opacity 0.2s;
-}
-
-.timestamp-header .clear-date-button:hover {
-  opacity: 1;
-  background: var(--hover-background, rgba(0, 0, 0, 0.05));
-}
-
-.timestamp-header .clear-date-button svg {
-  width: 0.75rem;
-  height: 0.75rem;
-}
-
 </style>
 </style>

+ 23 - 116
frontend/resources/vue/views/LogsQueueView.vue

@@ -1,12 +1,13 @@
 <template>
 <template>
   <Section
   <Section
     :title="t('logs.queue-title')"
     :title="t('logs.queue-title')"
+    :icon="Queue01Icon"
     :padding="false"
     :padding="false"
   >
   >
     <template #toolbar>
     <template #toolbar>
       <router-link
       <router-link
         to="/logs"
         to="/logs"
-        class="button neutral"
+        class="button neutral inline-icon"
       >
       >
         <svg
         <svg
           xmlns="http://www.w3.org/2000/svg"
           xmlns="http://www.w3.org/2000/svg"
@@ -58,90 +59,21 @@
     </div>
     </div>
 
 
     <div class="section-content">
     <div class="section-content">
-      <table class="logs-table row-hover">
-        <thead>
-          <tr>
-            <th>{{ t('logs.timestamp') }}</th>
-            <th>{{ t('logs.action') }}</th>
-            <th>{{ t('logs.metadata') }}</th>
-            <th>{{ t('logs.status') }}</th>
-          </tr>
-        </thead>
-        <tbody>
-          <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>
-                <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"
-                >
-                  {{ action.entityPrefix }}
-                </span>
-              </td>
-              <td class="tags">
-                <span class="annotation">
-                  <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>
-              </td>
-              <td class="exit-code">
-                <span
-                  v-if="!entry.executionFinished"
-                  class="queue-position"
-                >
-                  {{ t('logs.queue-position', { position: index + 1 }) }}
-                </span>
-                <ActionStatusDisplay
-                  :log-entry="entry"
-                  link-queued-status
-                />
-              </td>
-            </tr>
-          </template>
-        </tbody>
-      </table>
+      <ExecutionLogsTable
+        :logs="queueRowsForGroup(actionGroup)"
+        :loading="loading"
+      />
     </div>
     </div>
   </section>
   </section>
 </template>
 </template>
 
 
 <script setup>
 <script setup>
 import { ref, onMounted, onUnmounted } from 'vue'
 import { ref, onMounted, onUnmounted } from 'vue'
+import { Queue01Icon } from '@hugeicons/core-free-icons'
 import Section from 'picocrank/vue/components/Section.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
-import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
-import LogActionTitle from '../components/LogActionTitle.vue'
 import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
 import ActionGroupLimitsLabel from '../components/ActionGroupLimitsLabel.vue'
+import ExecutionLogsTable from '../components/ExecutionLogsTable.vue'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
 import { getExecutionLogEntry, cloneLogEntry, updateLogEntryInGroups } from '../utils/executionLogEvents.js'
 import { getExecutionLogEntry, cloneLogEntry, updateLogEntryInGroups } from '../utils/executionLogEvents.js'
 
 
@@ -350,15 +282,22 @@ function onExecutionFinished (evt) {
   applyQueueEntryUpdate(logEntry)
   applyQueueEntryUpdate(logEntry)
 }
 }
 
 
-function formatTimestamp (timestamp) {
-  if (!timestamp) {
-    return 'Unknown'
-  }
-  try {
-    return new Date(timestamp).toLocaleString()
-  } catch (err) {
-    return timestamp
+function queueRowsForGroup (actionGroup) {
+  const rows = []
+
+  for (const action of actionGroup.actions || []) {
+    for (const [index, entry] of (action.entries || []).entries()) {
+      rows.push({
+        ...entry,
+        actionTitle: action.actionTitle,
+        actionIcon: action.actionIcon,
+        entityPrefix: action.entityPrefix,
+        queuePosition: index + 1
+      })
+    }
   }
   }
+
+  return rows
 }
 }
 
 
 async function fetchQueue () {
 async function fetchQueue () {
@@ -399,38 +338,6 @@ onUnmounted(() => {
   margin: 0;
   margin: 0;
 }
 }
 
 
-.timestamp {
-  font-family: monospace;
-  font-size: 0.875rem;
-  color: #666;
-}
-
-.icon {
-  margin-right: 0.5rem;
-  font-size: 1.2em;
-}
-
-.annotation {
-  font-weight: 500;
-  font-size: smaller;
-}
-
-.queue-entity {
-  display: block;
-  margin-top: 0.25rem;
-  color: #666;
-}
-
-.exit-code {
-  display: flex;
-  align-items: center;
-  gap: 0.5rem;
-}
-
-.queue-position {
-  white-space: nowrap;
-}
-
 .empty-state {
 .empty-state {
   text-align: center;
   text-align: center;
   padding: 2rem;
   padding: 2rem;

+ 2 - 0
frontend/resources/vue/views/UserControlPanel.vue

@@ -1,6 +1,7 @@
 <template>
 <template>
   <Section
   <Section
     title="User Information"
     title="User Information"
+    :icon="UserIcon"
     class="small"
     class="small"
   >
   >
     <div
     <div
@@ -64,6 +65,7 @@
 <script setup>
 <script setup>
 import { ref, onMounted, onUnmounted } from 'vue'
 import { ref, onMounted, onUnmounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { useRouter } from 'vue-router'
+import { UserIcon } from '@hugeicons/core-free-icons'
 import Section from 'picocrank/vue/components/Section.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 
 
 const router = useRouter()
 const router = useRouter()

+ 1 - 16
frontend/style.css

@@ -1,20 +1,5 @@
 @layer components, karma, theme;
 @layer components, karma, theme;
 
 
-header {
-	position: fixed;
-	width: 100%;
-	z-index: 5;
-}
-
-aside {
-	padding-top: 4em;
-	z-index: 3; /* Make sure the sidebar is on top of the terminal */
-}
-
-main {
-	padding-top: 4em;
-}
-
 dialog {
 dialog {
 	border-radius: 1em;
 	border-radius: 1em;
 }
 }
@@ -62,4 +47,4 @@ section.small {
 
 
 .sm-transparent-footer footer span {
 .sm-transparent-footer footer span {
 	background-color: transparent;
 	background-color: transparent;
-}
+}

+ 13 - 19
frontend/vite.config.js → frontend/vite.config.mjs

@@ -1,17 +1,11 @@
 import { defineConfig } from 'vite'
 import { defineConfig } from 'vite'
 import vue from '@vitejs/plugin-vue'
 import vue from '@vitejs/plugin-vue'
-import Components from 'unplugin-vue-components/vite'
 
 
 export default defineConfig({
 export default defineConfig({
-  plugins: [
-    Components({
-      dirs: ['resources/vue/'],
-      extensions: ['vue'],
-      deep: true,
-      dts: false,
-    }),
-    vue(),
-  ],
+  resolve: {
+    dedupe: ['vue', 'vue-router']
+  },
+  plugins: [vue()],
   build: {
   build: {
     rolldownOptions: {
     rolldownOptions: {
       onLog (level, log, defaultHandler) {
       onLog (level, log, defaultHandler) {
@@ -19,25 +13,25 @@ export default defineConfig({
           return
           return
         }
         }
         defaultHandler(level, log)
         defaultHandler(level, log)
-      },
-    },
+      }
+    }
   },
   },
   server: {
   server: {
     proxy: {
     proxy: {
       '/api': {
       '/api': {
         target: 'http://localhost:1337',
         target: 'http://localhost:1337',
         changeOrigin: true,
         changeOrigin: true,
-        secure: false,
+        secure: false
       },
       },
       '/theme.css': {
       '/theme.css': {
         target: 'http://localhost:1337',
         target: 'http://localhost:1337',
         changeOrigin: true,
         changeOrigin: true,
-        secure: false,
+        secure: false
       },
       },
-      "/custom-webui": {
-        target: "http://localhost:1337",
-        changeOrigin: true,
+      '/custom-webui': {
+        target: 'http://localhost:1337',
+        changeOrigin: true
       }
       }
-    },
-  },
+    }
+  }
 })
 })

+ 0 - 8
integration-tests/.eslintrc.yml

@@ -1,8 +0,0 @@
-env:
-  browser: true
-  es2021: true
-extends: 'eslint:recommended'
-parserOptions:
-  ecmaVersion: 12
-  sourceType: module
-rules: {}

+ 11 - 3
integration-tests/Makefile

@@ -1,15 +1,23 @@
 default: test-install prep test-run
 default: test-install prep test-run
 
 
 test-install:
 test-install:
-	npm install --no-fund
+	npm ci --no-fund
+
+codestyle: test-install
+	npm run lint
+
+codestyle-fix: test-install
+	npm run lint:fix
 
 
 prep:
 prep:
+ifneq ($(SKIP_WEBUI),1)
 	$(MAKE) -wC .. webui-dist
 	$(MAKE) -wC .. webui-dist
+endif
 	$(MAKE) -wC ../service compile-currentenv
 	$(MAKE) -wC ../service compile-currentenv
 
 
 test-run:
 test-run:
     # GitHub Actions fails badly on the default timeout of 2000ms
     # GitHub Actions fails badly on the default timeout of 2000ms
-	npx mocha tests --recursive -t 10000
+	npm test
 
 
 find-flakey-tests:
 find-flakey-tests:
 	echo "Running test-run infinately"
 	echo "Running test-run infinately"
@@ -28,4 +36,4 @@ getsnapshot:
 	rm -rf /opt/OliveTin-snapshot/*
 	rm -rf /opt/OliveTin-snapshot/*
 	gh run download -D /opt/OliveTin-snapshot/
 	gh run download -D /opt/OliveTin-snapshot/
 
 
-.PHONY: default find-flakey-tests find-flakey-tests-inf prep
+.PHONY: default test-install codestyle codestyle-fix test-run find-flakey-tests find-flakey-tests-inf prep

+ 31 - 0
integration-tests/eslint.config.mjs

@@ -0,0 +1,31 @@
+import js from '@eslint/js'
+import globals from 'globals'
+
+export default [
+  {
+    ignores: [
+      'node_modules/**',
+      'tests/customJs/custom-webui/**'
+    ]
+  },
+  js.configs.recommended,
+  {
+    files: ['**/*.{js,mjs}'],
+    languageOptions: {
+      ecmaVersion: 'latest',
+      sourceType: 'module',
+      globals: {
+        ...globals.browser,
+        ...globals.node,
+        after: 'readonly',
+        afterEach: 'readonly',
+        before: 'readonly',
+        beforeEach: 'readonly',
+        describe: 'readonly',
+        it: 'readonly',
+        runner: 'readonly',
+        webdriver: 'readonly'
+      }
+    }
+  }
+]

+ 163 - 72
integration-tests/lib/elements.js

@@ -4,6 +4,7 @@ import { expect } from 'chai'
 import { Condition } from 'selenium-webdriver'
 import { Condition } from 'selenium-webdriver'
 
 
 export const DEFAULT_UI_WAIT_MS = 3000
 export const DEFAULT_UI_WAIT_MS = 3000
+const FAST_POLL_MS = 50
 
 
 // Keep Selenium helpers in lockstep with the frontend DOM id helpers.
 // Keep Selenium helpers in lockstep with the frontend DOM id helpers.
 export {
 export {
@@ -15,6 +16,67 @@ export {
 } from '../../frontend/resources/vue/utils/argumentFieldIds.js'
 } from '../../frontend/resources/vue/utils/argumentFieldIds.js'
 
 
 const executionDialogStatusBy = By.css('.execution-dialog-status')
 const executionDialogStatusBy = By.css('.execution-dialog-status')
+const sidebarIds = ['mainnav', 'picocrank-sidebar']
+
+const sidebarNavLinksSelector = [
+  sidebarCss('menu.navigation-links > li:not(.nav-section)'),
+  sidebarCss('menu.nav-section-links > li')
+].join(', ')
+
+let loadedPageGeneration = null
+
+function sidebarCss (suffix) {
+  return sidebarIds.map((id) => `#${id} ${suffix}`).join(', ')
+}
+
+function isSamePagePath (currentUrl, targetUrl) {
+  try {
+    return new URL(currentUrl).pathname === new URL(targetUrl).pathname
+  } catch {
+    return false
+  }
+}
+
+async function waitUntil (description, fn, timeoutMs = DEFAULT_UI_WAIT_MS) {
+  await webdriver.wait(
+    new Condition(description, fn),
+    timeoutMs,
+    undefined,
+    FAST_POLL_MS
+  )
+}
+
+async function getBodyAttribute (name) {
+  return webdriver.executeScript(
+    (attributeName) => document.body.getAttribute(attributeName),
+    name
+  )
+}
+
+async function isSidebarVisibleInBrowser () {
+  return webdriver.executeScript((ids) => {
+    for (const id of ids) {
+      const sidebar = document.getElementById(id)
+      if (!sidebar) {
+        continue
+      }
+
+      const classes = sidebar.className
+      if (classes.includes('shown') || classes.includes('stuck')) {
+        return true
+      }
+    }
+
+    return false
+  }, sidebarIds)
+}
+
+async function countNavigationLinksInBrowser () {
+  return webdriver.executeScript(
+    (selector) => document.querySelectorAll(selector).length,
+    sidebarNavLinksSelector
+  )
+}
 
 
 export async function getActionButtons () {
 export async function getActionButtons () {
   // Currently, only the active dashboard's contents are rendered,
   // Currently, only the active dashboard's contents are rendered,
@@ -23,10 +85,10 @@ export async function getActionButtons () {
 }
 }
 
 
 export async function getExecutionDialogOutput() {
 export async function getExecutionDialogOutput() {
-    await webdriver.wait(new Condition('Dialog with long int is visible', async () => {
+    await waitUntil('Dialog with long int is visible', async () => {
       const dialog = await webdriver.findElement({ id: 'execution-results-popup' })
       const dialog = await webdriver.findElement({ id: 'execution-results-popup' })
       return await dialog.isDisplayed()
       return await dialog.isDisplayed()
-    }));
+    })
 
 
     const ret = await webdriver.executeScript('return window.logEntries.get(window.executionDialog.executionTrackingId).output')
     const ret = await webdriver.executeScript('return window.logEntries.get(window.executionDialog.executionTrackingId).output')
 
 
@@ -52,7 +114,7 @@ export function takeScreenshot (webdriver, title) {
     fs.mkdirSync('screenshots', { recursive: true });
     fs.mkdirSync('screenshots', { recursive: true });
 
 
   title = title.replaceAll('config: ', '')
   title = title.replaceAll('config: ', '')
-	title = title.replaceAll(/[\(\)\|\*\<\>\:]/g, "_")
+	title = title.replaceAll(/[()|*<>:]/g, '_')
 	title = title + '.failed-test'
 	title = title + '.failed-test'
 
 
     fs.writeFileSync('screenshots/' + title + '.png', img, 'base64')
     fs.writeFileSync('screenshots/' + title + '.png', img, 'base64')
@@ -60,11 +122,8 @@ export function takeScreenshot (webdriver, title) {
 }
 }
 
 
 export async function waitForDashboardLoaded(timeoutMs = DEFAULT_UI_WAIT_MS, expectedTitle = null) {
 export async function waitForDashboardLoaded(timeoutMs = DEFAULT_UI_WAIT_MS, expectedTitle = null) {
-  await webdriver.wait(new Condition('wait for loaded-dashboard', async function () {
-    const body = await webdriver.findElement(By.tagName('body'))
-    const attr = await body.getAttribute('loaded-dashboard')
-
-    console.log('loaded-dashboard: ', attr)
+  await waitUntil('wait for loaded-dashboard', async function () {
+    const attr = await getBodyAttribute('loaded-dashboard')
 
 
     if (attr == null || attr === '') {
     if (attr == null || attr === '') {
       return false
       return false
@@ -75,118 +134,133 @@ export async function waitForDashboardLoaded(timeoutMs = DEFAULT_UI_WAIT_MS, exp
     }
     }
 
 
     return true
     return true
-  }), timeoutMs)
+  }, timeoutMs)
 }
 }
 
 
 export async function waitForLogsPage(timeoutMs = DEFAULT_UI_WAIT_MS) {
 export async function waitForLogsPage(timeoutMs = DEFAULT_UI_WAIT_MS) {
-  await webdriver.wait(new Condition('wait for logs page', async () => {
+  await waitUntil('wait for logs page', async () => {
     const url = await webdriver.getCurrentUrl()
     const url = await webdriver.getCurrentUrl()
     return url.includes('/logs/') && !url.endsWith('/logs')
     return url.includes('/logs/') && !url.endsWith('/logs')
-  }), timeoutMs)
+  }, timeoutMs)
 }
 }
 
 
 export async function waitForArgumentFormPage(timeoutMs = DEFAULT_UI_WAIT_MS) {
 export async function waitForArgumentFormPage(timeoutMs = DEFAULT_UI_WAIT_MS) {
-  await webdriver.wait(new Condition('wait for argument form page', async () => {
+  await waitUntil('wait for argument form page', async () => {
     const url = await webdriver.getCurrentUrl()
     const url = await webdriver.getCurrentUrl()
     return url.includes('/actionBinding/') && url.includes('/argumentForm')
     return url.includes('/actionBinding/') && url.includes('/argumentForm')
-  }), timeoutMs)
+  }, timeoutMs)
 }
 }
 
 
 export async function waitForArgumentFormReady(timeoutMs = DEFAULT_UI_WAIT_MS) {
 export async function waitForArgumentFormReady(timeoutMs = DEFAULT_UI_WAIT_MS) {
-  await webdriver.wait(new Condition('wait for argument form ready', async () => {
-    const body = await webdriver.findElement(By.tagName('body'))
-    const attr = await body.getAttribute('loaded-argument-form')
+  await waitUntil('wait for argument form ready', async () => {
+    const attr = await getBodyAttribute('loaded-argument-form')
     return attr != null && attr !== ''
     return attr != null && attr !== ''
-  }), timeoutMs)
+  }, timeoutMs)
 }
 }
 
 
 export async function waitForExecutionComplete(timeoutMs = DEFAULT_UI_WAIT_MS) {
 export async function waitForExecutionComplete(timeoutMs = DEFAULT_UI_WAIT_MS) {
-  await webdriver.wait(new Condition('wait for execution status', async () => {
+  await waitUntil('wait for execution to finish', async () => {
     const statusElements = await webdriver.findElements(executionDialogStatusBy)
     const statusElements = await webdriver.findElements(executionDialogStatusBy)
-    return statusElements.length > 0
-  }), timeoutMs)
+    if (statusElements.length === 0) {
+      return false
+    }
 
 
-  await webdriver.wait(new Condition('wait for execution to finish', async () => {
     try {
     try {
-      const statusElement = await webdriver.findElement(executionDialogStatusBy)
-      const statusText = await statusElement.getText()
+      const statusText = await statusElements[0].getText()
       return !statusText.includes('Still running') && !statusText.includes('Queued')
       return !statusText.includes('Still running') && !statusText.includes('Queued')
-    } catch (e) {
+    } catch {
       return false
       return false
     }
     }
-  }), timeoutMs)
+  }, timeoutMs)
 }
 }
 
 
 export async function getRootAndWait() {
 export async function getRootAndWait() {
-  await webdriver.get(runner.baseUrl())
+  const targetUrl = runner.baseUrl()
+  const sameConfig = loadedPageGeneration === runner.pageGeneration
+
+  if (sameConfig && isSamePagePath(await webdriver.getCurrentUrl(), targetUrl)) {
+    const attr = await getBodyAttribute('loaded-dashboard')
+    if (attr != null && attr !== '') {
+      return
+    }
+  }
+
+  await webdriver.get(targetUrl)
   await waitForDashboardLoaded()
   await waitForDashboardLoaded()
+  loadedPageGeneration = runner.pageGeneration
+}
+
+async function isSidebarVisible () {
+  return isSidebarVisibleInBrowser()
 }
 }
 
 
 export async function closeSidebar() {
 export async function closeSidebar() {
-  await webdriver.findElement(By.id('sidebar-toggler-button')).click()
+  if (await isSidebarVisible()) {
+    await webdriver.findElement(By.id('sidebar-toggler-button')).click()
+  }
 
 
-  const sidebar = await webdriver.findElement(By.id('mainnav'))
+  await waitUntil('wait for sidebar to close', async () => {
+    return !(await isSidebarVisible())
+  })
+}
 
 
-  const neededLeft = '-250px' // Assuming sidebar is closed at this position
+export async function openSidebar() {
+  if (await isSidebarVisible()) {
+    return
+  }
 
 
-  let lastLeft = ''
+  await webdriver.findElement(By.id('sidebar-toggler-button')).click()
 
 
-  await webdriver.wait(new Condition('wait for sidebar to close', async function() {
-    const left = await sidebar.getCssValue('left')
+  await waitUntil('wait for sidebar to open', async () => {
+    return await isSidebarVisible()
+  })
+}
 
 
-    if (left !== lastLeft) {
-      lastLeft = left
-      console.log('Sidebar left changed to: ', left)
-      return false
-    } else {
-      console.log('Sidebar closed, left is: *' + left, left === neededLeft ? ' (as expected)' : '')
-      return left === neededLeft
-    }
-  }), DEFAULT_UI_WAIT_MS)
+export async function getNavigationLinks() {
+  return await webdriver.findElements(By.css(sidebarNavLinksSelector))
 }
 }
 
 
-export async function openSidebar() {
-  await webdriver.findElement(By.id('sidebar-toggler-button')).click()
+export async function getNavigationLinkTitles () {
+  return webdriver.executeScript((selector) => {
+    return [...document.querySelectorAll(selector)].map((linkElement) => {
+      const title = linkElement.getAttribute('title')
+      if (title) {
+        return title
+      }
 
 
-  const sidebar = await webdriver.findElement(By.id('mainnav'))
+      const anchor = linkElement.querySelector('a[href]')
+      return anchor ? anchor.textContent.trim() : ''
+    })
+  }, sidebarNavLinksSelector)
+}
 
 
-  let lastLeft = 0
+export async function waitForNavigationLinks (minimumCount = 1, timeoutMs = DEFAULT_UI_WAIT_MS) {
+  await waitUntil(`wait for at least ${minimumCount} navigation links`, async () => {
+    const count = await countNavigationLinksInBrowser()
+    return count >= minimumCount
+  }, timeoutMs)
+}
 
 
-  await webdriver.wait(new Condition('wait for sidebar to open', async function() {
-    const left = await sidebar.getCssValue('left')
+export async function getNavigationLinkTitle (linkElement) {
+  const title = await linkElement.getAttribute('title')
+  if (title) {
+    return title
+  }
 
 
-    if (left !== lastLeft) {
-      lastLeft = left
-      console.log('Sidebar left changed to: ', left)
-      return false
-    } else {
-      console.log('Sidebar opened, left is: ', left)
-      return true
-    }
-  }), DEFAULT_UI_WAIT_MS)
+  const anchor = await linkElement.findElement(By.css('a[href]'))
+  return await anchor.getText()
 }
 }
 
 
-export async function getNavigationLinks() {
-  // Exclude section containers and legacy section header rows; count only link items.
-  const navigationLinks = await webdriver.findElements(
-    By.css('.navigation-links li:not(.nav-section-header-item):not(.nav-section)')
-  )
-
-  return navigationLinks
+export async function findSidebarNavHref (href) {
+  return await webdriver.findElements(By.css(sidebarCss(`a[href="${href}"]`)))
 }
 }
 
 
 export async function requireExecutionDialogStatus (webdriver, expected) {
 export async function requireExecutionDialogStatus (webdriver, expected) {
-  await webdriver.wait(new Condition('wait for action to be running', async function () {
+  await waitUntil('wait for action to be running', async function () {
     const dialogStatus = await webdriver.findElement(executionDialogStatusBy)
     const dialogStatus = await webdriver.findElement(executionDialogStatusBy)
     const actual = await dialogStatus.getText()
     const actual = await dialogStatus.getText()
-
-    if (actual === expected) {
-      return true
-    } else {
-      console.log('Waiting for domStatus text to be: ', expected, ', it is currently: ', actual)
-      return false
-    }
-  }), DEFAULT_UI_WAIT_MS)
+    return actual === expected
+  })
 }
 }
 
 
 export async function findExecutionDialog (webdriver) {
 export async function findExecutionDialog (webdriver) {
@@ -215,3 +289,20 @@ export async function getTerminalBuffer() {
     return null
     return null
   }
   }
 }
 }
+
+export async function waitForCurrentUrl (predicate, timeoutMs = DEFAULT_UI_WAIT_MS) {
+  await waitUntil('wait for url', async () => {
+    const url = await webdriver.getCurrentUrl()
+    return predicate(url)
+  }, timeoutMs)
+}
+
+export async function waitForSelectorCount (selector, minimumCount = 1, timeoutMs = DEFAULT_UI_WAIT_MS) {
+  await waitUntil(`wait for at least ${minimumCount} ${selector}`, async () => {
+    const count = await webdriver.executeScript(
+      (cssSelector) => document.querySelectorAll(cssSelector).length,
+      selector
+    )
+    return count >= minimumCount
+  }, timeoutMs)
+}

+ 2 - 1
integration-tests/mochaSetup.mjs

@@ -4,9 +4,10 @@ import getRunner from './runner.mjs'
 
 
 export async function mochaGlobalSetup () {
 export async function mochaGlobalSetup () {
   const options = new Options()
   const options = new Options()
-  options.addArguments('--headless')
+  options.addArguments('--headless', '--disable-gpu', '--disable-dev-shm-usage')
 
 
   global.webdriver = await new Builder().forBrowser(Browser.CHROME).setChromeOptions(options).build()
   global.webdriver = await new Builder().forBrowser(Browser.CHROME).setChromeOptions(options).build()
+  await global.webdriver.manage().setTimeouts({ implicit: 0, pageLoad: 30000, script: 30000 })
 
 
   global.runner = getRunner()
   global.runner = getRunner()
 
 

Diff do ficheiro suprimidas por serem muito extensas
+ 199 - 468
integration-tests/package-lock.json


+ 8 - 4
integration-tests/package.json

@@ -6,15 +6,19 @@
   "main": "index.js",
   "main": "index.js",
   "type": "module",
   "type": "module",
   "scripts": {
   "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1"
+    "lint": "eslint tests lib scripts runner.mjs mochaSetup.mjs",
+    "lint:fix": "npm run lint -- --fix",
+    "test": "mocha tests --recursive -t 10000"
   },
   },
   "author": "",
   "author": "",
   "license": "AGPL-3.0-only",
   "license": "AGPL-3.0-only",
   "devDependencies": {
   "devDependencies": {
+    "@eslint/js": "^10.0.1",
     "chai": "^6.2.2",
     "chai": "^6.2.2",
-    "eslint": "^10.8.0",
-    "mocha": "^11.7.6",
-    "selenium-webdriver": "^4.46.0"
+    "eslint": "^10.10.0",
+    "globals": "^17.3.0",
+    "mocha": "^12.0.0",
+    "selenium-webdriver": "^4.49.0"
   },
   },
   "dependencies": {
   "dependencies": {
     "wait-on": "^9.1.0"
     "wait-on": "^9.1.0"

+ 6 - 1
integration-tests/runner.mjs

@@ -21,6 +21,7 @@ export default function getRunner () {
 
 
 class OliveTinTestRunner {
 class OliveTinTestRunner {
   BASE_URL = 'http://nohost:1337/';
   BASE_URL = 'http://nohost:1337/';
+  pageGeneration = 0
 
 
   baseUrl() {
   baseUrl() {
     return this.BASE_URL
     return this.BASE_URL
@@ -37,6 +38,8 @@ class OliveTinTestRunnerStartLocalProcess extends OliveTinTestRunner {
       await this.stop()
       await this.stop()
     }
     }
 
 
+    this.pageGeneration += 1
+
     let stdout = ""
     let stdout = ""
     let stderr = ""
     let stderr = ""
 
 
@@ -137,7 +140,7 @@ class OliveTinTestRunnerStartLocalProcess extends OliveTinTestRunner {
       // GitHub runners seem to need a bit more time to clean up
       // GitHub runners seem to need a bit more time to clean up
       await new Promise((res) => setTimeout(res, 3000))
       await new Promise((res) => setTimeout(res, 3000))
     } else {
     } else {
-      await new Promise((res) => setTimeout(res, 100))
+      await new Promise((res) => setTimeout(res, 50))
     }
     }
   }
   }
 }
 }
@@ -171,6 +174,8 @@ class OliveTinTestRunnerVm extends OliveTinTestRunnerEnv {
   }
   }
 
 
   async start (cfg) {
   async start (cfg) {
+    this.pageGeneration += 1
+
     console.log("vagrant changing config")
     console.log("vagrant changing config")
     spawn('vagrant', ['ssh', '-c', '"ln -sf /etc/OliveTin/ /opt/OliveTin-configs/' + cfg + '/config.yaml"'])
     spawn('vagrant', ['ssh', '-c', '"ln -sf /etc/OliveTin/ /opt/OliveTin-configs/' + cfg + '/config.yaml"'])
     spawn('vagrant', ['ssh', '-c', '"systemctl restart OliveTin"'])
     spawn('vagrant', ['ssh', '-c', '"systemctl restart OliveTin"'])

+ 1 - 1
integration-tests/scripts/find-flakey-tests-inf.mjs

@@ -104,7 +104,7 @@ function runMochaOnce () {
       try {
       try {
         report = JSON.parse(readFileSync(reportPath, 'utf8'))
         report = JSON.parse(readFileSync(reportPath, 'utf8'))
       } catch {
       } catch {
-        report = null
+        // Keep the default null report when Mocha did not produce valid JSON.
       }
       }
 
 
       try {
       try {

+ 1 - 1
integration-tests/tests/argumentActionFlash/argumentActionFlash.mjs

@@ -28,7 +28,7 @@ async function waitForActionSuccessFlash (actionTitle) {
         const button = await getActionButton(webdriver, actionTitle)
         const button = await getActionButton(webdriver, actionTitle)
         const classAttr = await button.getAttribute('class')
         const classAttr = await button.getAttribute('class')
         return classAttr && classAttr.includes('action-success')
         return classAttr && classAttr.includes('action-success')
-      } catch (e) {
+      } catch {
         return false
         return false
       }
       }
     }),
     }),

+ 1 - 1
integration-tests/tests/argumentIdCollision/argumentIdCollision.mjs

@@ -45,7 +45,7 @@ async function waitForTerminalOutput (expectedSubstring) {
 
 
         const output = await getTerminalBuffer()
         const output = await getTerminalBuffer()
         return output && output.includes(expectedSubstring)
         return output && output.includes(expectedSubstring)
-      } catch (e) {
+      } catch {
         return false
         return false
       }
       }
     }),
     }),

+ 9 - 5
integration-tests/tests/authRequireGuestsToLogin/authRequireGuestsToLogin.mjs

@@ -1,8 +1,7 @@
 import { describe, it, before, after } from 'mocha'
 import { describe, it, before, after } from 'mocha'
 import { expect } from 'chai'
 import { expect } from 'chai'
-import { By, until } from 'selenium-webdriver'
+import { By } from 'selenium-webdriver'
 import {
 import {
-  getRootAndWait,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 } from '../../lib/elements.js'
 
 
@@ -26,8 +25,14 @@ describe('config: authRequireGuestsToLogin', function () {
 
 
     await webdriver.get(runner.baseUrl())
     await webdriver.get(runner.baseUrl())
 
 
-    await webdriver.wait(until.urlContains('/login'), 10000)
-    
+    await webdriver.wait(async () => {
+      const loginRequired = await webdriver.executeScript(
+        'return !!(window.initResponse && window.initResponse.loginRequired)'
+      )
+      const url = await webdriver.getCurrentUrl()
+      return loginRequired && url.includes('/login')
+    }, 15000, 'Guest should be redirected to login after Init')
+
     // Verify login UI elements are present
     // Verify login UI elements are present
     const loginElements = await webdriver.findElements(By.css('form.local-login-form, .login-oauth2, .login-disabled'))
     const loginElements = await webdriver.findElements(By.css('form.local-login-form, .login-oauth2, .login-disabled'))
     expect(loginElements.length).to.be.greaterThan(0)
     expect(loginElements.length).to.be.greaterThan(0)
@@ -36,4 +41,3 @@ describe('config: authRequireGuestsToLogin', function () {
 
 
   })
   })
 })
 })
-

+ 1 - 1
integration-tests/tests/checkbox/checkbox.mjs

@@ -47,7 +47,7 @@ async function waitForTerminalOutput(expectedValue) {
         }
         }
 
 
         return output.trim().includes(`Checkbox value: ${expectedValue}`)
         return output.trim().includes(`Checkbox value: ${expectedValue}`)
-      } catch (e) {
+      } catch {
         return false
         return false
       }
       }
     }),
     }),

+ 1 - 1
integration-tests/tests/checklist/checklist.mjs

@@ -44,7 +44,7 @@ async function pollTerminal(matcher, timeoutMs = DEFAULT_UI_WAIT_MS) {
         }
         }
 
 
         return matcher(output.trim())
         return matcher(output.trim())
-      } catch (e) {
+      } catch {
         return false
         return false
       }
       }
     }),
     }),

+ 37 - 0
integration-tests/tests/confirmation/config.yaml

@@ -0,0 +1,37 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:1337
+
+logLevel: "DEBUG"
+checkForUpdates: false
+defaultPopupOnStart: execution-dialog
+
+actions:
+  # Docs: https://docs.olivetin.app/args/input_confirmation.html
+  # Unnamed confirmation is a UI-only gate; nothing is substituted into the command.
+  - title: Test unnamed confirmation argument
+    exec:
+      - echo
+      - "Confirmed action ran"
+    icon: ping
+    arguments:
+      - type: confirmation
+        title: Are you sure?!
+
+  # Named confirmation values are only ever 0 or 1, so they are safe with shell actions.
+  - title: Test named confirmation argument
+    exec:
+      - echo
+      - "Confirmation value: {{ agree }}"
+    icon: ping
+    arguments:
+      - name: agree
+        type: confirmation
+        title: I understand the consequences
+
+  - title: Test shell with named confirmation
+    shell: "echo 'Shell confirmation value: {{ agree }}'"
+    icon: ping
+    arguments:
+      - name: agree
+        type: confirmation
+        title: Are you sure?!

+ 161 - 0
integration-tests/tests/confirmation/confirmation.mjs

@@ -0,0 +1,161 @@
+import { describe, it, before, after } from 'mocha'
+import { expect } from 'chai'
+import { By, Condition } from 'selenium-webdriver'
+import {
+  DEFAULT_UI_WAIT_MS,
+  argumentFieldId,
+  getRootAndWait,
+  getActionButton,
+  takeScreenshotOnFailure,
+  waitForArgumentFormPage,
+  waitForArgumentFormReady,
+  waitForLogsPage,
+  waitForExecutionComplete,
+  getTerminalBuffer,
+} from '../../lib/elements.js'
+
+async function openArgumentForm (actionTitle) {
+  await getRootAndWait()
+  const btn = await getActionButton(webdriver, actionTitle)
+  await btn.click()
+  await waitForArgumentFormPage()
+  await waitForArgumentFormReady()
+}
+
+async function getStartButton () {
+  return await webdriver.findElement(By.css('button[name="start"]'))
+}
+
+async function waitForStartButtonEnabled () {
+  await webdriver.wait(
+    new Condition('wait for Start button to be enabled', async () => {
+      const submitButton = await getStartButton()
+      return await submitButton.isEnabled()
+    }),
+    DEFAULT_UI_WAIT_MS
+  )
+}
+
+async function waitForStartButtonDisabled () {
+  await webdriver.wait(
+    new Condition('wait for Start button to be disabled', async () => {
+      const submitButton = await getStartButton()
+      return !(await submitButton.isEnabled())
+    }),
+    DEFAULT_UI_WAIT_MS
+  )
+}
+
+async function waitForTerminalOutput (expectedSubstring) {
+  await webdriver.wait(
+    new Condition(`wait for terminal output containing ${expectedSubstring}`, async () => {
+      try {
+        const terminalReady = await webdriver.executeScript(`
+          return !!(window.terminal && window.terminal.getBufferAsString);
+        `)
+        if (!terminalReady) {
+          return false
+        }
+
+        const output = await getTerminalBuffer()
+        return output && output.includes(expectedSubstring)
+      } catch {
+        return false
+      }
+    }),
+    DEFAULT_UI_WAIT_MS
+  )
+}
+
+async function confirmAndSubmit (checkbox) {
+  await checkbox.click()
+  await waitForStartButtonEnabled()
+  const submitButton = await getStartButton()
+  await submitButton.click()
+  await waitForLogsPage()
+  await waitForExecutionComplete()
+}
+
+describe('config: confirmation', function () {
+  this.timeout(10000)
+
+  before(async function () {
+    await runner.start('confirmation')
+  })
+
+  after(async () => {
+    await runner.stop()
+  })
+
+  afterEach(function () {
+    takeScreenshotOnFailure(this.currentTest, webdriver)
+  })
+
+  it('Unnamed confirmation renders a checkbox and disables Start until ticked', async function () {
+    await openArgumentForm('Test unnamed confirmation argument')
+
+    const checkbox = await webdriver.findElement(By.css('#argument-popup input[type="checkbox"]'))
+    expect(await checkbox.getTagName()).to.equal('input')
+    expect(await checkbox.getAttribute('type')).to.equal('checkbox')
+    expect(await checkbox.isSelected()).to.be.false
+
+    const label = await webdriver.findElement(By.css(`label[for="${argumentFieldId('')}"]`))
+    expect(await label.getText()).to.contain('Are you sure?!')
+
+    const submitButton = await getStartButton()
+    expect(await submitButton.isEnabled()).to.be.false
+  })
+
+  it('Unnamed confirmation runs the action without substituting a value', async function () {
+    await openArgumentForm('Test unnamed confirmation argument')
+
+    const checkbox = await webdriver.findElement(By.css('#argument-popup input[type="checkbox"]'))
+    await confirmAndSubmit(checkbox)
+    await waitForTerminalOutput('Confirmed action ran')
+  })
+
+  it('Named confirmation disables Start until ticked and submits 1 when checked', async function () {
+    await openArgumentForm('Test named confirmation argument')
+
+    const checkbox = await webdriver.findElement(By.id(argumentFieldId('agree')))
+    expect(await checkbox.isSelected()).to.be.false
+
+    const label = await webdriver.findElement(By.css(`label[for="${argumentFieldId('agree')}"]`))
+    expect(await label.getText()).to.contain('I understand the consequences')
+
+    const submitButton = await getStartButton()
+    expect(await submitButton.isEnabled()).to.be.false
+
+    await confirmAndSubmit(checkbox)
+    await waitForTerminalOutput('Confirmation value: 1')
+  })
+
+  it('Named confirmation works with shell actions', async function () {
+    await openArgumentForm('Test shell with named confirmation')
+
+    const checkbox = await webdriver.findElement(By.id(argumentFieldId('agree')))
+    const submitButton = await getStartButton()
+    expect(await submitButton.isEnabled()).to.be.false
+
+    await confirmAndSubmit(checkbox)
+    await waitForTerminalOutput('Shell confirmation value: 1')
+  })
+
+  it('Confirmation keeps Start disabled while unchecked (unlike checkbox arguments)', async function () {
+    await openArgumentForm('Test named confirmation argument')
+
+    const checkbox = await webdriver.findElement(By.id(argumentFieldId('agree')))
+    expect(await checkbox.isSelected()).to.be.false
+    await waitForStartButtonDisabled()
+
+    await checkbox.click()
+    await webdriver.sleep(100)
+    expect(await checkbox.isSelected()).to.be.true
+    await waitForStartButtonEnabled()
+
+    await checkbox.click()
+    await webdriver.sleep(100)
+    expect(await checkbox.isSelected()).to.be.false
+    await waitForStartButtonDisabled()
+  })
+})

+ 42 - 1
integration-tests/tests/cssClass/cssClass.mjs

@@ -1,11 +1,40 @@
 import { describe, it, before, after, afterEach } from 'mocha'
 import { describe, it, before, after, afterEach } from 'mocha'
 import { expect } from 'chai'
 import { expect } from 'chai'
-import { By } from 'selenium-webdriver'
+import { By, Condition } from 'selenium-webdriver'
 import {
 import {
+  DEFAULT_UI_WAIT_MS,
   getRootAndWait,
   getRootAndWait,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 } from '../../lib/elements.js'
 
 
+async function waitForThemeCss () {
+  await webdriver.wait(
+    new Condition('wait for theme CSS to load', async () => {
+      const body = await webdriver.findElement(By.tagName('body'))
+      const loadedTheme = await body.getAttribute('loaded-theme')
+      if (!loadedTheme) {
+        return false
+      }
+
+      return await webdriver.executeScript(`
+        const style = document.getElementById('theme-style');
+        return !!(style && style.textContent && style.textContent.includes('test-custom-class'));
+      `)
+    }),
+    DEFAULT_UI_WAIT_MS
+  )
+}
+
+async function waitForCssColor (element, channelPattern, description) {
+  await webdriver.wait(
+    new Condition(`wait for ${description}`, async () => {
+      const bgColor = await element.getCssValue('background-color')
+      return channelPattern.test(bgColor)
+    }),
+    DEFAULT_UI_WAIT_MS
+  )
+}
+
 describe('config: cssClass', function () {
 describe('config: cssClass', function () {
   before(async function () {
   before(async function () {
     await runner.start('cssClass')
     await runner.start('cssClass')
@@ -31,10 +60,16 @@ describe('config: cssClass', function () {
 
 
   it('custom theme applies background color to action button via cssClass', async function () {
   it('custom theme applies background color to action button via cssClass', async function () {
     await getRootAndWait()
     await getRootAndWait()
+    await waitForThemeCss()
 
 
     const buttonWithClass = await webdriver.findElements(By.css('.action-button button.test-custom-class'))
     const buttonWithClass = await webdriver.findElements(By.css('.action-button button.test-custom-class'))
     expect(buttonWithClass).to.have.length.at.least(1, 'Action button with test-custom-class should exist')
     expect(buttonWithClass).to.have.length.at.least(1, 'Action button with test-custom-class should exist')
 
 
+    await waitForCssColor(
+      buttonWithClass[0],
+      /rgba?\(\s*32\s*,\s*64\s*,\s*128\s*(,\s*1)?\s*\)/,
+      'theme action button background'
+    )
     const bgColor = await buttonWithClass[0].getCssValue('background-color')
     const bgColor = await buttonWithClass[0].getCssValue('background-color')
     expect(bgColor, 'Theme theme.css should set .action-button button.test-custom-class background to rgb(32, 64, 128)')
     expect(bgColor, 'Theme theme.css should set .action-button button.test-custom-class background to rgb(32, 64, 128)')
       .to.match(/rgba?\(\s*32\s*,\s*64\s*,\s*128\s*(,\s*1)?\s*\)/)
       .to.match(/rgba?\(\s*32\s*,\s*64\s*,\s*128\s*(,\s*1)?\s*\)/)
@@ -77,10 +112,16 @@ describe('config: cssClass', function () {
 
 
   it('custom theme applies background color to display component via cssClass', async function () {
   it('custom theme applies background color to display component via cssClass', async function () {
     await getRootAndWait()
     await getRootAndWait()
+    await waitForThemeCss()
 
 
     const displayElements = await webdriver.findElements(By.css('.display.test-display-class'))
     const displayElements = await webdriver.findElements(By.css('.display.test-display-class'))
     expect(displayElements).to.have.length.at.least(1, 'Display with test-display-class should exist')
     expect(displayElements).to.have.length.at.least(1, 'Display with test-display-class should exist')
 
 
+    await waitForCssColor(
+      displayElements[0],
+      /rgba?\(\s*64\s*,\s*128\s*,\s*192\s*(,\s*1)?\s*\)/,
+      'theme display background'
+    )
     const bgColor = await displayElements[0].getCssValue('background-color')
     const bgColor = await displayElements[0].getCssValue('background-color')
     expect(bgColor, 'Theme theme.css should set .display.test-display-class background to rgb(64, 128, 192)')
     expect(bgColor, 'Theme theme.css should set .display.test-display-class background to rgb(64, 128, 192)')
       .to.match(/rgba?\(\s*64\s*,\s*128\s*,\s*192\s*(,\s*1)?\s*\)/)
       .to.match(/rgba?\(\s*64\s*,\s*128\s*,\s*192\s*(,\s*1)?\s*\)/)

+ 3 - 8
integration-tests/tests/dashboardAcls/dashboardAcls.mjs

@@ -3,7 +3,7 @@ import { expect } from 'chai'
 import {
 import {
   getRootAndWait,
   getRootAndWait,
   openSidebar,
   openSidebar,
-  getNavigationLinks,
+  getNavigationLinkTitles,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 } from '../../lib/elements.js'
 
 
@@ -24,13 +24,8 @@ describe('config: dashboardAcls', function () {
     await getRootAndWait()
     await getRootAndWait()
     await openSidebar()
     await openSidebar()
 
 
-    const navigationLinks = await getNavigationLinks()
-    expect(navigationLinks).to.not.be.empty
-
-    const linkTexts = []
-    for (const link of navigationLinks) {
-      linkTexts.push(await link.getText())
-    }
+    const linkTexts = await getNavigationLinkTitles()
+    expect(linkTexts).to.not.be.empty
 
 
     expect(linkTexts).to.include('Public tools')
     expect(linkTexts).to.include('Public tools')
     expect(linkTexts).to.not.include('Services')
     expect(linkTexts).to.not.include('Services')

+ 7 - 2
integration-tests/tests/dashboardsWithBasicFieldsets/dashboardsWithBasicFieldsets.js → integration-tests/tests/dashboardsWithBasicFieldsets/dashboardsWithBasicFieldsets.mjs

@@ -1,16 +1,20 @@
 import { describe, it, before, after } from 'mocha'
 import { describe, it, before, after } from 'mocha'
 import { expect, assert } from 'chai'
 import { expect, assert } from 'chai'
-import { By, until, Condition } from 'selenium-webdriver'
+import { By } from 'selenium-webdriver'
 //import * as waitOn from 'wait-on'
 //import * as waitOn from 'wait-on'
 import {
 import {
   getRootAndWait,
   getRootAndWait,
   getActionButtons,
   getActionButtons,
   openSidebar,
   openSidebar,
   getNavigationLinks,
   getNavigationLinks,
+  getNavigationLinkTitle,
+  waitForNavigationLinks,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 } from '../../lib/elements.js'
 
 
 describe('config: dashboards with basic fieldsets', function () {
 describe('config: dashboards with basic fieldsets', function () {
+  this.timeout(30000)
+
   before(async function () {
   before(async function () {
     await runner.start('dashboardsWithBasicFieldsets')
     await runner.start('dashboardsWithBasicFieldsets')
   })
   })
@@ -30,13 +34,14 @@ describe('config: dashboards with basic fieldsets', function () {
     expect(title).to.be.equal("Test - OliveTin")
     expect(title).to.be.equal("Test - OliveTin")
 
 
     await openSidebar()
     await openSidebar()
+    await waitForNavigationLinks(4)
 
 
     const navigationLinks = await getNavigationLinks()
     const navigationLinks = await getNavigationLinks()
     assert.equal(navigationLinks.length, 4, 'Expected the nav to only have 4 links') // test dashboard + entities + logs + diagnostics
     assert.equal(navigationLinks.length, 4, 'Expected the nav to only have 4 links') // test dashboard + entities + logs + diagnostics
 
 
     const firstLink = await navigationLinks[0]
     const firstLink = await navigationLinks[0]
 
 
-    expect(await firstLink.getAttribute('title')).to.be.equal('Test', 'Expected the first link to be the actions link')
+    expect(await getNavigationLinkTitle(firstLink)).to.be.equal('Test', 'Expected the first link to be the actions link')
 
 
     const actionButtons = await getActionButtons()
     const actionButtons = await getActionButtons()
     expect(actionButtons).to.have.length(5, 'Expected 5 action buttons')
     expect(actionButtons).to.have.length(5, 'Expected 5 action buttons')

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