Jelajahi Sumber

fix: Migrate much of the UI to modern picocrank, cleanup some dead code, improve tests

jamesread 1 hari lalu
induk
melakukan
23221aa687
53 mengubah file dengan 1722 tambahan dan 1029 penghapusan
  1. 4 4
      AGENTS.md
  2. 4 0
      CONTRIBUTING.adoc
  3. 1 1
      Makefile
  4. 4 1
      frontend/main.js
  5. 90 20
      frontend/package-lock.json
  6. 1 1
      frontend/package.json
  7. 201 179
      frontend/resources/vue/App.vue
  8. 6 4
      frontend/resources/vue/Dashboard.vue
  9. 0 64
      frontend/resources/vue/components/Breadcrumbs.vue
  10. 5 1
      frontend/resources/vue/components/EntityDefinitionSection.vue
  11. 224 0
      frontend/resources/vue/components/ExecutionLogsTable.vue
  12. 16 12
      frontend/resources/vue/router.js
  13. 27 0
      frontend/resources/vue/utils/themeLoader.js
  14. 42 139
      frontend/resources/vue/views/ActionDetailsView.vue
  15. 2 0
      frontend/resources/vue/views/ActionExecConditionsView.vue
  16. 63 76
      frontend/resources/vue/views/DiagnosticsView.vue
  17. 3 0
      frontend/resources/vue/views/EntitiesView.vue
  18. 7 32
      frontend/resources/vue/views/EntityDetailsView.vue
  19. 32 8
      frontend/resources/vue/views/ExecutionView.vue
  20. 2 0
      frontend/resources/vue/views/LoginView.vue
  21. 3 13
      frontend/resources/vue/views/LogsCalendarView.vue
  22. 44 153
      frontend/resources/vue/views/LogsListView.vue
  23. 23 116
      frontend/resources/vue/views/LogsQueueView.vue
  24. 2 0
      frontend/resources/vue/views/UserControlPanel.vue
  25. 1 16
      frontend/style.css
  26. 3 0
      frontend/vite.config.js
  27. 162 71
      integration-tests/lib/elements.js
  28. 2 1
      integration-tests/mochaSetup.mjs
  29. 152 62
      integration-tests/package-lock.json
  30. 2 2
      integration-tests/package.json
  31. 4 1
      integration-tests/runner.mjs
  32. 8 3
      integration-tests/tests/authRequireGuestsToLogin/authRequireGuestsToLogin.mjs
  33. 37 0
      integration-tests/tests/confirmation/config.yaml
  34. 161 0
      integration-tests/tests/confirmation/confirmation.mjs
  35. 42 1
      integration-tests/tests/cssClass/cssClass.mjs
  36. 3 8
      integration-tests/tests/dashboardAcls/dashboardAcls.mjs
  37. 6 1
      integration-tests/tests/dashboardsWithBasicFieldsets/dashboardsWithBasicFieldsets.js
  38. 4 0
      integration-tests/tests/emptyDashboardsAreHidden/emptyDashboardsAreHidden.js
  39. 11 20
      integration-tests/tests/general/general.mjs
  40. 45 0
      integration-tests/tests/justification/config.yaml
  41. 242 0
      integration-tests/tests/justification/justification.mjs
  42. 7 11
      integration-tests/tests/multi-dashboard-includes/multi-dashboard-includes.mjs
  43. 6 0
      lang/combined_output.json
  44. 1 0
      lang/de-DE.yaml
  45. 1 0
      lang/en.yaml
  46. 1 0
      lang/es-ES.yaml
  47. 1 0
      lang/it-IT.yaml
  48. 1 0
      lang/zh-Hans-CN.yaml
  49. 1 0
      lang/zh-Hant-TW.yaml
  50. 9 0
      service/.golangci.yml
  51. 1 2
      service/Makefile
  52. 1 4
      service/scripts/find-flakey-tests-inf/Makefile
  53. 1 2
      var/tekton/gobuilder/Dockerfile

+ 4 - 4
AGENTS.md

@@ -21,10 +21,10 @@ 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`)
 - 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 +44,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`).

+ 4 - 0
CONTRIBUTING.adoc

@@ -52,6 +52,10 @@ pre-commit install --hook-type pre-push
 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;

+ 1 - 1
Makefile

@@ -79,4 +79,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 service windows-resources windows-msi frontend-unittests it devcheck

+ 4 - 1
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,6 +18,7 @@ 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 { applyThemeStyles, getStoredThemePreference } from './resources/vue/utils/themeLoader.js'
 import combinedTranslations from '../lang/combined_output.json'
 import combinedTranslations from '../lang/combined_output.json'
 
 
 function getSelectedLanguage () {
 function getSelectedLanguage () {
@@ -118,6 +119,8 @@ async function main () {
   try {
   try {
     const i18nSettings = await initClient()
     const i18nSettings = await initClient()
 
 
+    await applyThemeStyles(getStoredThemePreference())
+
     initWebsocket()
     initWebsocket()
 
 
     setupVue(i18nSettings)
     setupVue(i18nSettings)

+ 90 - 20
frontend/package-lock.json

@@ -18,7 +18,7 @@
 				"@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.28.0",
+				"picocrank": "^1.29.0",
 				"standard": "^17.1.2",
 				"standard": "^17.1.2",
 				"unplugin-vue-components": "^32.1.0",
 				"unplugin-vue-components": "^32.1.0",
 				"vite": "^8.2.2",
 				"vite": "^8.2.2",
@@ -1450,21 +1450,21 @@
 			"license": "MIT"
 			"license": "MIT"
 		},
 		},
 		"node_modules/@vue/devtools-kit": {
 		"node_modules/@vue/devtools-kit": {
-			"version": "8.1.5",
-			"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.5.tgz",
-			"integrity": "sha512-FcSAxsi4eWuXLCB7Rv9lj0aIVHHPNVQ2BazGf4RJTc2JCqb4BQg0hk87ZFhminCfl+mD5OUI0rX2cgyu4kJOGA==",
+			"version": "8.2.1",
+			"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.2.1.tgz",
+			"integrity": "sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==",
 			"license": "MIT",
 			"license": "MIT",
 			"dependencies": {
 			"dependencies": {
-				"@vue/devtools-shared": "^8.1.5",
+				"@vue/devtools-shared": "^8.2.1",
 				"birpc": "^2.6.1",
 				"birpc": "^2.6.1",
 				"hookable": "^5.5.3",
 				"hookable": "^5.5.3",
 				"perfect-debounce": "^2.0.0"
 				"perfect-debounce": "^2.0.0"
 			}
 			}
 		},
 		},
 		"node_modules/@vue/devtools-shared": {
 		"node_modules/@vue/devtools-shared": {
-			"version": "8.1.5",
-			"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.5.tgz",
-			"integrity": "sha512-mhT4zcPFhF+Xk1O4BfhhrbXzpmfqY03fS6xGpcllbQG7lDjhQf8pQHcTIhqQIYx1hfwtHmk/6jM96ele0UxPqQ==",
+			"version": "8.2.1",
+			"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.2.1.tgz",
+			"integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==",
 			"license": "MIT"
 			"license": "MIT"
 		},
 		},
 		"node_modules/@vue/reactivity": {
 		"node_modules/@vue/reactivity": {
@@ -2012,10 +2012,11 @@
 			"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
 			"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
 		},
 		},
 		"node_modules/colord": {
 		"node_modules/colord": {
-			"version": "2.9.3",
-			"resolved": "https://registry.npmjs.org/colord/-/colord-2.9.3.tgz",
-			"integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==",
-			"dev": true
+			"version": "2.10.0",
+			"resolved": "https://registry.npmjs.org/colord/-/colord-2.10.0.tgz",
+			"integrity": "sha512-AidJptpBJmjTclAp9BkLwJi0T93fo5epJnbaZslpg6QVzpHjAiveF55mE9AcUJiGMqRHgMDY8soMsQtuNYMHfw==",
+			"dev": true,
+			"license": "MIT"
 		},
 		},
 		"node_modules/concat-map": {
 		"node_modules/concat-map": {
 			"version": "0.0.1",
 			"version": "0.0.1",
@@ -3100,9 +3101,9 @@
 			"license": "MIT"
 			"license": "MIT"
 		},
 		},
 		"node_modules/fast-uri": {
 		"node_modules/fast-uri": {
-			"version": "3.1.5",
-			"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz",
-			"integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==",
+			"version": "3.1.7",
+			"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
+			"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
 			"dev": true,
 			"dev": true,
 			"funding": [
 			"funding": [
 				{
 				{
@@ -5130,9 +5131,9 @@
 			"license": "ISC"
 			"license": "ISC"
 		},
 		},
 		"node_modules/picocrank": {
 		"node_modules/picocrank": {
-			"version": "1.28.0",
-			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.28.0.tgz",
-			"integrity": "sha512-VeeJfGrfClO0m5dGC0DiulRxl5NTL8IAzB87QtpXO4dyIoz8h1Rq+JAtCL7TwGKNMBGk5HD32duEw/pSMEHWIA==",
+			"version": "1.29.0",
+			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.29.0.tgz",
+			"integrity": "sha512-7aT12lJ7g0iNqfTTZ5EaWWCU6jwoTQhk1vU2dLxsvb89AkoQ0TL08fKrUa1dOlUgD9UAN9v4tWdyzt3Gu2zM5Q==",
 			"license": "ISC",
 			"license": "ISC",
 			"dependencies": {
 			"dependencies": {
 				"@hugeicons/core-free-icons": "^4.3.0",
 				"@hugeicons/core-free-icons": "^4.3.0",
@@ -5141,8 +5142,77 @@
 				"femtocrank": "^2.7.1",
 				"femtocrank": "^2.7.1",
 				"unplugin-vue-components": "^32.1.0",
 				"unplugin-vue-components": "^32.1.0",
 				"vite": "^8.2.2",
 				"vite": "^8.2.2",
-				"vue": "^3.5.41",
-				"vue-router": "^5.2.0"
+				"vue": "^3.5.42",
+				"vue-router": "^5.3.1"
+			}
+		},
+		"node_modules/picocrank/node_modules/@vue/devtools-api": {
+			"version": "8.2.1",
+			"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.2.1.tgz",
+			"integrity": "sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==",
+			"license": "MIT",
+			"dependencies": {
+				"@vue/devtools-kit": "^8.2.1"
+			}
+		},
+		"node_modules/picocrank/node_modules/picomatch": {
+			"version": "4.0.7",
+			"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
+			"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
+			"license": "MIT",
+			"engines": {
+				"node": ">=12"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/jonschlinkert"
+			}
+		},
+		"node_modules/picocrank/node_modules/vue-router": {
+			"version": "5.3.1",
+			"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.3.1.tgz",
+			"integrity": "sha512-GDBZzgmILxA/kFnkFbJjQZdZ2QQbngnIMMuoUcjhZIfH1RGMaPjPwX5ASnV38qamuA9uhO0RDjSBHTDNG2uXyQ==",
+			"license": "MIT",
+			"dependencies": {
+				"@vue-macros/common": "^3.1.3",
+				"@vue/devtools-api": "^8.1.5",
+				"ast-walker-scope": "^0.9.0",
+				"chokidar": "^5.0.0",
+				"confbox": "^0.2.4",
+				"local-pkg": "^1.2.1",
+				"magic-string": "^0.30.21",
+				"mlly": "^1.8.2",
+				"muggle-string": "^0.4.1",
+				"nostics": "^1.1.4",
+				"pathe": "^2.0.3",
+				"picomatch": "^4.0.5",
+				"scule": "^1.3.0",
+				"tinyglobby": "^0.2.17",
+				"unplugin": "^3.3.0",
+				"unplugin-utils": "^0.3.2"
+			},
+			"funding": {
+				"url": "https://github.com/sponsors/posva"
+			},
+			"peerDependencies": {
+				"@pinia/colada": ">=0.21.2",
+				"@vue/compiler-sfc": "^3.5.34 || ^4.0.0",
+				"pinia": "^3.0.4 || ^4.0.2",
+				"vite": "^7.3.0 || ^8.0.0",
+				"vue": "^3.5.34 || ^4.0.0"
+			},
+			"peerDependenciesMeta": {
+				"@pinia/colada": {
+					"optional": true
+				},
+				"@vue/compiler-sfc": {
+					"optional": true
+				},
+				"pinia": {
+					"optional": true
+				},
+				"vite": {
+					"optional": true
+				}
 			}
 			}
 		},
 		},
 		"node_modules/picomatch": {
 		"node_modules/picomatch": {

+ 1 - 1
frontend/package.json

@@ -32,7 +32,7 @@
 		"@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.28.0",
+		"picocrank": "^1.29.0",
 		"standard": "^17.1.2",
 		"standard": "^17.1.2",
 		"unplugin-vue-components": "^32.1.0",
 		"unplugin-vue-components": "^32.1.0",
 		"vite": "^8.2.2",
 		"vite": "^8.2.2",

+ 201 - 179
frontend/resources/vue/App.vue

@@ -1,176 +1,179 @@
 <template>
 <template>
-  <Header
-    :title="pageTitle"
-    :logo-url="logoUrl"
-    :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"
-      >
-        <p>{{ bannerMessage }}</p>
-      </div>
-      <ConnectionBanner />
-    </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"
+  <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"
     >
     >
-      <main title="Main content">
-        <router-view :key="$route.fullPath" />
-      </main>
+      <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>
+        <ConnectionBanner />
+      </template>
+    </Header>
+
+    <div id="layout">
+      <Navigation ref="navigation">
+        <Sidebar
+          v-if="sidebarEnabled && showNavigation"
+          id="mainnav"
+          ref="sidebar"
+        />
+      </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>
@@ -187,6 +190,7 @@ import logoUrl from '../../OliveTinLogo.png'
 import { useI18n } from 'vue-i18n'
 import { useI18n } from 'vue-i18n'
 import combinedTranslations from '../../../lang/combined_output.json'
 import combinedTranslations from '../../../lang/combined_output.json'
 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()
@@ -302,7 +306,7 @@ function goToUserControlPanel () {
   router.push({ name: 'UserInformation' })
   router.push({ name: 'UserInformation' })
 }
 }
 
 
-function updateHeaderFromInit () {
+async function updateHeaderFromInit () {
   if (!window.initResponse) {
   if (!window.initResponse) {
     return
     return
   }
   }
@@ -336,7 +340,7 @@ function updateHeaderFromInit () {
   loadCustomJsIfEnabled()
   loadCustomJsIfEnabled()
 
 
   renderNavigation()
   renderNavigation()
-  applyTheme()
+  await applyTheme()
 
 
   if (loginRequired.value) {
   if (loginRequired.value) {
     connectEventStreamIfNeeded()
     connectEventStreamIfNeeded()
@@ -465,8 +469,31 @@ 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 () {
@@ -533,7 +560,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 = ''
@@ -542,25 +569,20 @@ 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)
+    const themeStyle = document.getElementById('theme-style')
+    if (themeStyle) {
+      themeStyle.textContent = ''
+    }
+    document.body.removeAttribute('loaded-theme')
   }
   }
 }
 }
 
 
@@ -595,8 +617,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

+ 6 - 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

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

+ 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'

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

@@ -0,0 +1,224 @@
+<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="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"
+          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>

+ 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) {

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

@@ -0,0 +1,27 @@
+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' })
+  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') || ''
+}

+ 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()

+ 63 - 76
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">
@@ -21,7 +24,10 @@
     </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>
@@ -35,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>
@@ -44,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
@@ -53,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'
@@ -118,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' },
@@ -168,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 = ''
@@ -177,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 {
@@ -256,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`
@@ -287,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 {
@@ -295,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)

+ 3 - 0
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.
@@ -22,6 +24,7 @@
 
 
 <script setup>
 <script setup>
 import { ref, computed, onMounted } from 'vue'
 import { ref, computed, onMounted } 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)

+ 7 - 32
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,7 +23,7 @@
           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="!entityDetails">
@@ -63,6 +65,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"
@@ -126,7 +129,7 @@
 import { ref, computed, onMounted, onUnmounted } 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'
@@ -182,25 +185,6 @@ onUnmounted(() => {
 </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;
@@ -243,15 +227,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);
     }
     }

+ 32 - 8
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"
@@ -357,6 +364,20 @@ async function fetchExecutionResult (executionTrackingIdParam) {
   }
   }
 }
 }
 
 
+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 + ')'
     }
     }
   }
   }
 }
 }

+ 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;
-}
+}

+ 3 - 0
frontend/vite.config.js

@@ -3,6 +3,9 @@ import vue from '@vitejs/plugin-vue'
 import Components from 'unplugin-vue-components/vite'
 import Components from 'unplugin-vue-components/vite'
 
 
 export default defineConfig({
 export default defineConfig({
+  resolve: {
+    dedupe: ['vue', 'vue-router'],
+  },
   plugins: [
   plugins: [
     Components({
     Components({
       dirs: ['resources/vue/'],
       dirs: ['resources/vue/'],

+ 162 - 71
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')
 
 
@@ -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()
 
 

+ 152 - 62
integration-tests/package-lock.json

@@ -13,9 +13,9 @@
       },
       },
       "devDependencies": {
       "devDependencies": {
         "chai": "^6.2.2",
         "chai": "^6.2.2",
-        "eslint": "^10.9.1",
+        "eslint": "^10.10.0",
         "mocha": "^12.0.0",
         "mocha": "^12.0.0",
-        "selenium-webdriver": "^4.48.0"
+        "selenium-webdriver": "^4.49.0"
       }
       }
     },
     },
     "node_modules/@aashutoshrathi/word-wrap": {
     "node_modules/@aashutoshrathi/word-wrap": {
@@ -34,6 +34,30 @@
       "dev": true,
       "dev": true,
       "license": "Apache-2.0"
       "license": "Apache-2.0"
     },
     },
+    "node_modules/@cacheable/memory": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/@cacheable/memory/-/memory-2.2.0.tgz",
+      "integrity": "sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@cacheable/utils": "^2.5.0",
+        "@keyv/bigmap": "^1.3.1",
+        "hookified": "^1.15.1",
+        "keyv": "^5.6.0"
+      }
+    },
+    "node_modules/@cacheable/utils": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/@cacheable/utils/-/utils-2.5.0.tgz",
+      "integrity": "sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "hashery": "^1.5.1",
+        "keyv": "^5.6.0"
+      }
+    },
     "node_modules/@eslint-community/eslint-utils": {
     "node_modules/@eslint-community/eslint-utils": {
       "version": "4.9.0",
       "version": "4.9.0",
       "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
       "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
@@ -128,9 +152,9 @@
       }
       }
     },
     },
     "node_modules/@eslint/plugin-kit": {
     "node_modules/@eslint/plugin-kit": {
-      "version": "0.7.2",
-      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
-      "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
+      "version": "0.7.3",
+      "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.3.tgz",
+      "integrity": "sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==",
       "dev": true,
       "dev": true,
       "license": "Apache-2.0",
       "license": "Apache-2.0",
       "dependencies": {
       "dependencies": {
@@ -190,41 +214,41 @@
       }
       }
     },
     },
     "node_modules/@humanfs/core": {
     "node_modules/@humanfs/core": {
-      "version": "0.19.1",
-      "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
-      "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+      "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
       "dev": true,
       "dev": true,
       "license": "Apache-2.0",
       "license": "Apache-2.0",
+      "dependencies": {
+        "@humanfs/types": "^0.15.0"
+      },
       "engines": {
       "engines": {
         "node": ">=18.18.0"
         "node": ">=18.18.0"
       }
       }
     },
     },
     "node_modules/@humanfs/node": {
     "node_modules/@humanfs/node": {
-      "version": "0.16.6",
-      "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz",
-      "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==",
+      "version": "0.16.8",
+      "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+      "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
       "dev": true,
       "dev": true,
       "license": "Apache-2.0",
       "license": "Apache-2.0",
       "dependencies": {
       "dependencies": {
-        "@humanfs/core": "^0.19.1",
-        "@humanwhocodes/retry": "^0.3.0"
+        "@humanfs/core": "^0.19.2",
+        "@humanfs/types": "^0.15.0",
+        "@humanwhocodes/retry": "^0.4.0"
       },
       },
       "engines": {
       "engines": {
         "node": ">=18.18.0"
         "node": ">=18.18.0"
       }
       }
     },
     },
-    "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": {
-      "version": "0.3.1",
-      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz",
-      "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==",
+    "node_modules/@humanfs/types": {
+      "version": "0.15.0",
+      "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+      "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
       "dev": true,
       "dev": true,
       "license": "Apache-2.0",
       "license": "Apache-2.0",
       "engines": {
       "engines": {
-        "node": ">=18.18"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/nzakas"
+        "node": ">=18.18.0"
       }
       }
     },
     },
     "node_modules/@humanwhocodes/module-importer": {
     "node_modules/@humanwhocodes/module-importer": {
@@ -254,6 +278,30 @@
         "url": "https://github.com/sponsors/nzakas"
         "url": "https://github.com/sponsors/nzakas"
       }
       }
     },
     },
+    "node_modules/@keyv/bigmap": {
+      "version": "1.3.1",
+      "resolved": "https://registry.npmjs.org/@keyv/bigmap/-/bigmap-1.3.1.tgz",
+      "integrity": "sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "hashery": "^1.4.0",
+        "hookified": "^1.15.0"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "peerDependencies": {
+        "keyv": "^5.6.0"
+      }
+    },
+    "node_modules/@keyv/serialize": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/@keyv/serialize/-/serialize-1.1.1.tgz",
+      "integrity": "sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/@standard-schema/spec": {
     "node_modules/@standard-schema/spec": {
       "version": "1.1.0",
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
       "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -387,6 +435,20 @@
       "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
       "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
       "dev": true
       "dev": true
     },
     },
+    "node_modules/cacheable": {
+      "version": "2.5.0",
+      "resolved": "https://registry.npmjs.org/cacheable/-/cacheable-2.5.0.tgz",
+      "integrity": "sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@cacheable/memory": "^2.2.0",
+        "@cacheable/utils": "^2.5.0",
+        "hookified": "^1.15.0",
+        "keyv": "^5.6.0",
+        "qified": "^0.10.1"
+      }
+    },
     "node_modules/call-bind-apply-helpers": {
     "node_modules/call-bind-apply-helpers": {
       "version": "1.0.2",
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
       "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -573,9 +635,9 @@
       }
       }
     },
     },
     "node_modules/eslint": {
     "node_modules/eslint": {
-      "version": "10.9.1",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.9.1.tgz",
-      "integrity": "sha512-9VaAkDURekixUQJy0oJYl2DcN6oKMfxay7XzaGYAWQwsb6qfKf+x76R2k1L8kb1boc+FyCAaTA9GmiKaaiaF+A==",
+      "version": "10.10.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz",
+      "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "workspaces": [
       "workspaces": [
@@ -587,7 +649,7 @@
         "@eslint/config-array": "^0.23.5",
         "@eslint/config-array": "^0.23.5",
         "@eslint/config-helpers": "^0.7.0",
         "@eslint/config-helpers": "^0.7.0",
         "@eslint/core": "^1.2.1",
         "@eslint/core": "^1.2.1",
-        "@eslint/plugin-kit": "^0.7.2",
+        "@eslint/plugin-kit": "^0.7.3",
         "@humanfs/node": "^0.16.6",
         "@humanfs/node": "^0.16.6",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@humanwhocodes/retry": "^0.4.2",
         "@humanwhocodes/retry": "^0.4.2",
@@ -602,7 +664,7 @@
         "esquery": "^1.7.0",
         "esquery": "^1.7.0",
         "esutils": "^2.0.2",
         "esutils": "^2.0.2",
         "fast-deep-equal": "^3.1.3",
         "fast-deep-equal": "^3.1.3",
-        "file-entry-cache": "^8.0.0",
+        "file-entry-cache": "11.1.5 || >11.1.6 <12",
         "find-up": "^5.0.0",
         "find-up": "^5.0.0",
         "glob-parent": "^6.0.2",
         "glob-parent": "^6.0.2",
         "ignore": "^5.2.0",
         "ignore": "^5.2.0",
@@ -748,16 +810,13 @@
       "dev": true
       "dev": true
     },
     },
     "node_modules/file-entry-cache": {
     "node_modules/file-entry-cache": {
-      "version": "8.0.0",
-      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
-      "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+      "version": "11.1.5",
+      "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-11.1.5.tgz",
+      "integrity": "sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "flat-cache": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=16.0.0"
+        "flat-cache": "^6.1.23"
       }
       }
     },
     },
     "node_modules/find-up": {
     "node_modules/find-up": {
@@ -777,23 +836,21 @@
       }
       }
     },
     },
     "node_modules/flat-cache": {
     "node_modules/flat-cache": {
-      "version": "4.0.1",
-      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
-      "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+      "version": "6.1.23",
+      "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-6.1.23.tgz",
+      "integrity": "sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "flatted": "^3.2.9",
-        "keyv": "^4.5.4"
-      },
-      "engines": {
-        "node": ">=16"
+        "cacheable": "^2.5.0",
+        "flatted": "^3.4.2",
+        "hookified": "^1.15.0"
       }
       }
     },
     },
     "node_modules/flatted": {
     "node_modules/flatted": {
-      "version": "3.4.2",
-      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
-      "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
+      "version": "3.4.4",
+      "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz",
+      "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==",
       "dev": true,
       "dev": true,
       "license": "ISC"
       "license": "ISC"
     },
     },
@@ -957,6 +1014,19 @@
         "url": "https://github.com/sponsors/ljharb"
         "url": "https://github.com/sponsors/ljharb"
       }
       }
     },
     },
+    "node_modules/hashery": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz",
+      "integrity": "sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "hookified": "^1.15.0"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
     "node_modules/hasown": {
     "node_modules/hasown": {
       "version": "2.0.4",
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
       "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -969,6 +1039,13 @@
         "node": ">= 0.4"
         "node": ">= 0.4"
       }
       }
     },
     },
+    "node_modules/hookified": {
+      "version": "1.15.1",
+      "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
+      "integrity": "sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/https-proxy-agent": {
     "node_modules/https-proxy-agent": {
       "version": "5.0.1",
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
       "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -1070,9 +1147,9 @@
       "dev": true
       "dev": true
     },
     },
     "node_modules/joi": {
     "node_modules/joi": {
-      "version": "18.2.3",
-      "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.3.tgz",
-      "integrity": "sha512-N5A3KTWQpPWT4ExxxPlUx7WmykGXRzhNidWhV41d6Abu9YfI2NyWCJuxdPnslJCPWtbRpSVOWSnSS6GakLM/Rg==",
+      "version": "18.2.8",
+      "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.8.tgz",
+      "integrity": "sha512-G2TX62h58ZHuwqetJgP2F4ualakqAmZtBYe3jWen7gxQRw5xApX6crnFtuB91WC0c3ESBnva+kGSnb3+6pIQDQ==",
       "license": "BSD-3-Clause",
       "license": "BSD-3-Clause",
       "dependencies": {
       "dependencies": {
         "@hapi/address": "^5.1.1",
         "@hapi/address": "^5.1.1",
@@ -1110,13 +1187,6 @@
         "js-yaml": "bin/js-yaml.mjs"
         "js-yaml": "bin/js-yaml.mjs"
       }
       }
     },
     },
-    "node_modules/json-buffer": {
-      "version": "3.0.1",
-      "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
-      "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
-      "dev": true,
-      "license": "MIT"
-    },
     "node_modules/json-schema-traverse": {
     "node_modules/json-schema-traverse": {
       "version": "0.4.1",
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
       "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -1143,13 +1213,13 @@
       }
       }
     },
     },
     "node_modules/keyv": {
     "node_modules/keyv": {
-      "version": "4.5.4",
-      "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
-      "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+      "version": "5.6.0",
+      "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz",
+      "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "json-buffer": "3.0.1"
+        "@keyv/serialize": "^1.1.1"
       }
       }
     },
     },
     "node_modules/levn": {
     "node_modules/levn": {
@@ -1456,6 +1526,26 @@
         "node": ">=6"
         "node": ">=6"
       }
       }
     },
     },
+    "node_modules/qified": {
+      "version": "0.10.1",
+      "resolved": "https://registry.npmjs.org/qified/-/qified-0.10.1.tgz",
+      "integrity": "sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "hookified": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/qified/node_modules/hookified": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/hookified/-/hookified-2.2.0.tgz",
+      "integrity": "sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/readable-stream": {
     "node_modules/readable-stream": {
       "version": "2.3.8",
       "version": "2.3.8",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -1501,9 +1591,9 @@
       "dev": true
       "dev": true
     },
     },
     "node_modules/selenium-webdriver": {
     "node_modules/selenium-webdriver": {
-      "version": "4.48.0",
-      "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.48.0.tgz",
-      "integrity": "sha512-rKM9uXFRWcF9aThrZQDNQH2/9Et/WvMZbg3/x1rnSYWoXiwJuShYeH0IAli8Cuw+c3lEV0UWPfUz88H+fvW9Hg==",
+      "version": "4.49.0",
+      "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.49.0.tgz",
+      "integrity": "sha512-16XqeOEMj+4+p+pzLLyvNWmF5jCY05EKIl3e1anZBgv4+zq7PUTtiNjKtQhMpwosZ0wIMKDFAQ2xdyogOHD4DA==",
       "dev": true,
       "dev": true,
       "funding": [
       "funding": [
         {
         {

+ 2 - 2
integration-tests/package.json

@@ -12,9 +12,9 @@
   "license": "AGPL-3.0-only",
   "license": "AGPL-3.0-only",
   "devDependencies": {
   "devDependencies": {
     "chai": "^6.2.2",
     "chai": "^6.2.2",
-    "eslint": "^10.9.1",
+    "eslint": "^10.10.0",
     "mocha": "^12.0.0",
     "mocha": "^12.0.0",
-    "selenium-webdriver": "^4.48.0"
+    "selenium-webdriver": "^4.49.0"
   },
   },
   "dependencies": {
   "dependencies": {
     "wait-on": "^9.1.0"
     "wait-on": "^9.1.0"

+ 4 - 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))
     }
     }
   }
   }
 }
 }

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

@@ -26,8 +26,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 +42,3 @@ describe('config: authRequireGuestsToLogin', function () {
 
 
   })
   })
 })
 })
-

+ 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 (e) {
+        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')

+ 6 - 1
integration-tests/tests/dashboardsWithBasicFieldsets/dashboardsWithBasicFieldsets.js

@@ -7,10 +7,14 @@ import {
   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')

+ 4 - 0
integration-tests/tests/emptyDashboardsAreHidden/emptyDashboardsAreHidden.js

@@ -6,10 +6,13 @@ import {
   getRootAndWait,
   getRootAndWait,
   openSidebar,
   openSidebar,
   getNavigationLinks,
   getNavigationLinks,
+  waitForNavigationLinks,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 } from '../../lib/elements.js'
 
 
 describe('config: empty dashboards are hidden', function () {
 describe('config: empty dashboards are hidden', function () {
+  this.timeout(30000)
+
   before(async function () {
   before(async function () {
     await runner.start('emptyDashboardsAreHidden')
     await runner.start('emptyDashboardsAreHidden')
   })
   })
@@ -26,6 +29,7 @@ describe('config: empty dashboards are hidden', function () {
     await getRootAndWait()
     await getRootAndWait()
 
 
     await openSidebar()
     await openSidebar()
+    await waitForNavigationLinks(3)
 
 
     const title = await webdriver.getTitle()
     const title = await webdriver.getTitle()
     expect(title).to.be.equal("Actions - OliveTin")
     expect(title).to.be.equal("Actions - OliveTin")

+ 11 - 20
integration-tests/tests/general/general.mjs

@@ -1,12 +1,15 @@
 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, 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,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
   openSidebar,
   openSidebar,
+  findSidebarNavHref,
+  waitForCurrentUrl,
+  waitForSelectorCount,
 } from '../../lib/elements.js'
 } from '../../lib/elements.js'
 
 
 describe('config: general', function () {
 describe('config: general', function () {
@@ -34,8 +37,8 @@ describe('config: general', function () {
     await openSidebar()
     await openSidebar()
 
 
 
 
-    const logsLink = await webdriver.findElements(By.css('a[href="/logs"]'))
-    const diagnosticsLink = await webdriver.findElements(By.css('a[href="/diagnostics"]'))
+    const logsLink = await findSidebarNavHref('/logs')
+    const diagnosticsLink = await findSidebarNavHref('/diagnostics')
 
 
     expect(logsLink).to.not.be.empty
     expect(logsLink).to.not.be.empty
     expect(diagnosticsLink).to.not.be.empty
     expect(diagnosticsLink).to.not.be.empty
@@ -50,10 +53,7 @@ describe('config: general', function () {
   it('Default buttons are rendered', async function() {
   it('Default buttons are rendered', async function() {
     await getRootAndWait()
     await getRootAndWait()
 
 
-    await webdriver.wait(new Condition('wait for action buttons', async () => {
-      const btns = await webdriver.findElements(By.css('[title="dir-popup"], [title="cd-passive"], .action-button button'))
-      return btns.length >= 1
-    }), 10000)
+    await waitForSelectorCount('[title="dir-popup"], [title="cd-passive"], .action-button button', 1, 10000)
 
 
     const buttons = await getActionButtons()
     const buttons = await getActionButtons()
     expect(buttons.length).to.be.greaterThanOrEqual(4)
     expect(buttons.length).to.be.greaterThanOrEqual(4)
@@ -62,10 +62,7 @@ describe('config: general', function () {
   it('Start dir action (popup)', async function () {
   it('Start dir action (popup)', async function () {
     await getRootAndWait()
     await getRootAndWait()
 
 
-    await webdriver.wait(new Condition('wait for dir-popup button', async () => {
-      const btns = await webdriver.findElements(By.css('[title="dir-popup"]'))
-      return btns.length === 1
-    }), 10000)
+    await waitForSelectorCount('[title="dir-popup"]', 1, 10000)
 
 
     const buttons = await webdriver.findElements(By.css('[title="dir-popup"]'))
     const buttons = await webdriver.findElements(By.css('[title="dir-popup"]'))
 
 
@@ -78,19 +75,13 @@ describe('config: general', function () {
     buttonCMD.click()
     buttonCMD.click()
 
 
     // New UI navigates to /logs/<id> instead of showing old dialog
     // New UI navigates to /logs/<id> instead of showing old dialog
-    await webdriver.wait(new Condition('wait navigate to logs', async () => {
-      const url = await webdriver.getCurrentUrl()
-      return url.includes('/logs/')
-    }), 8000)
+    await waitForCurrentUrl((url) => url.includes('/logs/'), 8000)
   })
   })
 
 
   it('Start cd action (passive)', async function () {
   it('Start cd action (passive)', async function () {
     await getRootAndWait()
     await getRootAndWait()
 
 
-    await webdriver.wait(new Condition('wait for cd-passive button', async () => {
-      const btns = await webdriver.findElements(By.css('[title="cd-passive"]'))
-      return btns.length === 1
-    }), 10000)
+    await waitForSelectorCount('[title="cd-passive"]', 1, 10000)
 
 
     const buttons = await webdriver.findElements(By.css('[title="cd-passive"]'))
     const buttons = await webdriver.findElements(By.css('[title="cd-passive"]'))
 
 
@@ -102,7 +93,7 @@ describe('config: general', function () {
 
 
     buttonCMD.click()
     buttonCMD.click()
 
 
-    // Should not navigate to logs for passive action
+    // Should not navigate to logs for passive action; brief pause allows spurious navigation to show up
     await webdriver.sleep(500)
     await webdriver.sleep(500)
     const url = await webdriver.getCurrentUrl()
     const url = await webdriver.getCurrentUrl()
     expect(url.includes('/logs/')).to.be.false
     expect(url.includes('/logs/')).to.be.false

+ 45 - 0
integration-tests/tests/justification/config.yaml

@@ -0,0 +1,45 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:1337
+
+logLevel: "DEBUG"
+checkForUpdates: false
+defaultPopupOnStart: execution-dialog
+
+actions:
+  - title: Test action without justification
+    exec:
+      - echo
+      - "No justification needed"
+    icon: ping
+
+  # A single space requires justification with no prefilled template.
+  - title: Test manual justification
+    justification: " "
+    exec:
+      - echo
+      - "Manual justification action ran"
+    icon: ping
+
+  - title: Test templated justification
+    justification: "{{ target }}"
+    exec:
+      - echo
+      - "Target: {{ target }}"
+    icon: ping
+    arguments:
+      - name: target
+        title: Target host
+        type: ascii
+        default: dbserver
+
+  - title: Test justification with arguments
+    justification: " "
+    exec:
+      - echo
+      - "Target: {{ target }}"
+    icon: ping
+    arguments:
+      - name: target
+        title: Target host
+        type: ascii
+        default: testhost

+ 242 - 0
integration-tests/tests/justification/justification.mjs

@@ -0,0 +1,242 @@
+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 getJustificationInput () {
+  return await webdriver.findElement(By.id('justification'))
+}
+
+async function getJustificationValue () {
+  const input = await getJustificationInput()
+  return await input.getAttribute('value')
+}
+
+async function waitForJustificationValue (expected) {
+  await webdriver.wait(
+    new Condition(`wait for justification value "${expected}"`, async () => {
+      const value = await getJustificationValue()
+      return value === expected
+    }),
+    DEFAULT_UI_WAIT_MS
+  )
+}
+
+async function fillJustification (text) {
+  const input = await getJustificationInput()
+  await input.clear()
+  await input.sendKeys(text)
+  await webdriver.sleep(100)
+}
+
+async function fillArgumentField (argumentName, value) {
+  const input = await webdriver.findElement(By.id(argumentFieldId(argumentName)))
+  await webdriver.executeScript(
+    `const input = arguments[0];
+     input.value = arguments[1];
+     input.dispatchEvent(new Event('input', { bubbles: true }));
+     input.dispatchEvent(new Event('change', { bubbles: true }));`,
+    input,
+    value
+  )
+  await webdriver.sleep(100)
+}
+
+async function submitForm () {
+  const submitButton = await getStartButton()
+  await submitButton.click()
+}
+
+async function assertStillOnArgumentForm () {
+  const url = await webdriver.getCurrentUrl()
+  expect(url).to.include('/argumentForm')
+}
+
+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 (e) {
+        return false
+      }
+    }),
+    DEFAULT_UI_WAIT_MS
+  )
+}
+
+async function waitForExecutionJustification (expectedText) {
+  await webdriver.wait(
+    new Condition(`wait for execution justification "${expectedText}"`, async () => {
+      const elements = await webdriver.findElements(By.css('.log-justification'))
+      if (elements.length === 0) {
+        return false
+      }
+
+      const text = await elements[0].getText()
+      return text.includes(expectedText)
+    }),
+    DEFAULT_UI_WAIT_MS
+  )
+}
+
+async function submitWithJustification (text) {
+  await fillJustification(text)
+  await submitForm()
+  await waitForLogsPage()
+  await waitForExecutionComplete()
+}
+
+describe('config: justification', function () {
+  this.timeout(10000)
+
+  before(async function () {
+    await runner.start('justification')
+  })
+
+  after(async () => {
+    await runner.stop()
+  })
+
+  afterEach(function () {
+    takeScreenshotOnFailure(this.currentTest, webdriver)
+  })
+
+  it('Actions without justification skip the argument form', async function () {
+    await getRootAndWait()
+    const btn = await getActionButton(webdriver, 'Test action without justification')
+    await btn.click()
+
+    await webdriver.wait(
+      new Condition('navigates away from argument form', async () => {
+        const url = await webdriver.getCurrentUrl()
+        return url.includes('/logs/') && !url.includes('/argumentForm')
+      }),
+      DEFAULT_UI_WAIT_MS
+    )
+
+    await waitForExecutionComplete()
+    await waitForTerminalOutput('No justification needed')
+  })
+
+  it('Manual justification opens a required empty field with no arguments', async function () {
+    await openArgumentForm('Test manual justification')
+
+    const justificationInput = await getJustificationInput()
+    expect(await justificationInput.getTagName()).to.equal('input')
+    expect(await justificationInput.getAttribute('type')).to.equal('text')
+    expect(await justificationInput.getAttribute('required')).to.equal('true')
+    expect((await getJustificationValue()).trim()).to.equal('')
+
+    const label = await webdriver.findElement(By.css('label[for="justification"]'))
+    expect(await label.getText()).to.contain('Justification')
+
+    const argumentFields = await webdriver.findElements(By.css('[id^="arg-field-"]'))
+    expect(argumentFields).to.have.length(0)
+  })
+
+  it('Manual justification blocks submit until filled', async function () {
+    await openArgumentForm('Test manual justification')
+
+    await submitForm()
+    await assertStillOnArgumentForm()
+
+    const justificationInput = await getJustificationInput()
+    const validationMessage = await webdriver.executeScript(
+      'return arguments[0].validationMessage',
+      justificationInput
+    )
+    expect(validationMessage).to.not.equal('')
+  })
+
+  it('Manual justification is stored and shown on the execution page', async function () {
+    await openArgumentForm('Test manual justification')
+
+    await submitWithJustification('Approved maintenance window')
+    await waitForTerminalOutput('Manual justification action ran')
+    await waitForExecutionJustification('Approved maintenance window')
+  })
+
+  it('Templated justification prefills from argument defaults', async function () {
+    await openArgumentForm('Test templated justification')
+
+    const targetInput = await webdriver.findElement(By.id(argumentFieldId('target')))
+    expect(await targetInput.getAttribute('value')).to.equal('dbserver')
+    await waitForJustificationValue('dbserver')
+
+    await submitForm()
+    await waitForLogsPage()
+    await waitForExecutionComplete()
+    await waitForTerminalOutput('Target: dbserver')
+    await waitForExecutionJustification('dbserver')
+  })
+
+  it('Templated justification updates when argument values change', async function () {
+    await openArgumentForm('Test templated justification')
+
+    await waitForJustificationValue('dbserver')
+
+    await fillArgumentField('target', 'appserver')
+    await waitForJustificationValue('appserver')
+  })
+
+  it('Manual edits to templated justification are preserved when arguments change', async function () {
+    await openArgumentForm('Test templated justification')
+
+    await fillArgumentField('target', 'hosta')
+    await waitForJustificationValue('hosta')
+
+    await fillJustification('Manual override reason')
+    expect(await getJustificationValue()).to.equal('Manual override reason')
+
+    await fillArgumentField('target', 'hostb')
+    await webdriver.sleep(200)
+
+    expect(await getJustificationValue()).to.equal('Manual override reason')
+  })
+
+  it('Justification is required alongside regular arguments', async function () {
+    await openArgumentForm('Test justification with arguments')
+
+    const targetInput = await webdriver.findElement(By.id(argumentFieldId('target')))
+    expect(await targetInput.getAttribute('value')).to.equal('testhost')
+
+    await submitForm()
+    await assertStillOnArgumentForm()
+
+    await submitWithJustification('Deploy to testhost')
+    await waitForTerminalOutput('Target: testhost')
+    await waitForExecutionJustification('Deploy to testhost')
+  })
+})

+ 7 - 11
integration-tests/tests/multi-dashboard-includes/multi-dashboard-includes.mjs

@@ -5,6 +5,8 @@ import {
   DEFAULT_UI_WAIT_MS,
   DEFAULT_UI_WAIT_MS,
   getRootAndWait,
   getRootAndWait,
   getActionButtons,
   getActionButtons,
+  getNavigationLinkTitles,
+  getNavigationLinkTitle,
   getNavigationLinks,
   getNavigationLinks,
   openSidebar,
   openSidebar,
   takeScreenshotOnFailure,
   takeScreenshotOnFailure,
@@ -34,7 +36,7 @@ describe('config: multi-dashboard-includes', function () {
 
 
     const matching = []
     const matching = []
     for (const li of navigationLinks) {
     for (const li of navigationLinks) {
-      const liTitle = await li.getAttribute('title')
+      const liTitle = await getNavigationLinkTitle(li)
       if (liTitle === title) {
       if (liTitle === title) {
         matching.push(li)
         matching.push(li)
       }
       }
@@ -42,7 +44,8 @@ describe('config: multi-dashboard-includes', function () {
 
 
     assert.strictEqual(matching.length, 1, `Expected exactly one navigation link with title "${title}"`)
     assert.strictEqual(matching.length, 1, `Expected exactly one navigation link with title "${title}"`)
 
 
-    await matching[0].click()
+    const anchor = await matching[0].findElement(By.css('a[href]'))
+    await anchor.click()
     await waitForDashboardLoaded(DEFAULT_UI_WAIT_MS, title)
     await waitForDashboardLoaded(DEFAULT_UI_WAIT_MS, title)
   }
   }
 
 
@@ -61,13 +64,8 @@ describe('config: multi-dashboard-includes', function () {
     await getRootAndWait()
     await getRootAndWait()
 
 
     await openSidebar()
     await openSidebar()
-    const navigationLinks = await getNavigationLinks()
-    assert.isAbove(navigationLinks.length, 0, 'Expected navigation to have at least one link')
-
-    const titles = []
-    for (const li of navigationLinks) {
-      titles.push(await li.getAttribute('title'))
-    }
+    const titles = await getNavigationLinkTitles()
+    assert.isAbove(titles.length, 0, 'Expected navigation to have at least one link')
 
 
     expect(titles).to.include('First Dashboard')
     expect(titles).to.include('First Dashboard')
     expect(titles).to.include('Second Dashboard')
     expect(titles).to.include('Second Dashboard')
@@ -107,5 +105,3 @@ describe('config: multi-dashboard-includes', function () {
   })
   })
 
 
 })
 })
-
-

+ 6 - 0
lang/combined_output.json

@@ -51,6 +51,7 @@
             "logs.clear-date-filter": "Datumsfilter löschen",
             "logs.clear-date-filter": "Datumsfilter löschen",
             "logs.clear-filter": "Suchfilter löschen",
             "logs.clear-filter": "Suchfilter löschen",
             "logs.completed": "Abgeschlossen",
             "logs.completed": "Abgeschlossen",
+            "logs.execution-id": "Ausführungs-ID",
             "logs.exit-code": "Ausführungscode",
             "logs.exit-code": "Ausführungscode",
             "logs.filter-error": "Filterausdruck konnte nicht angewendet werden.",
             "logs.filter-error": "Filterausdruck konnte nicht angewendet werden.",
             "logs.filter-help-examples": "Beispiele: backup · !Update · Status != Completed · Status == Blocked · Action contains backup and Status == Completed",
             "logs.filter-help-examples": "Beispiele: backup · !Update · Status != Completed · Status == Blocked · Action contains backup and Status == Completed",
@@ -142,6 +143,7 @@
             "logs.clear-date-filter": "Clear date filter",
             "logs.clear-date-filter": "Clear date filter",
             "logs.clear-filter": "Clear search filter",
             "logs.clear-filter": "Clear search filter",
             "logs.completed": "Completed",
             "logs.completed": "Completed",
+            "logs.execution-id": "Execution ID",
             "logs.exit-code": "Exit code",
             "logs.exit-code": "Exit code",
             "logs.filter-error": "Could not apply filter expression.",
             "logs.filter-error": "Could not apply filter expression.",
             "logs.filter-help-examples": "Examples: backup · !Update · Status != Completed · Status == Blocked · Action contains backup and Status == Completed",
             "logs.filter-help-examples": "Examples: backup · !Update · Status != Completed · Status == Blocked · Action contains backup and Status == Completed",
@@ -233,6 +235,7 @@
             "logs.clear-date-filter": "Limpiar filtro de fecha",
             "logs.clear-date-filter": "Limpiar filtro de fecha",
             "logs.clear-filter": "Limpiar filtro de búsqueda",
             "logs.clear-filter": "Limpiar filtro de búsqueda",
             "logs.completed": "Completado",
             "logs.completed": "Completado",
+            "logs.execution-id": "ID de ejecución",
             "logs.exit-code": "Código de salida",
             "logs.exit-code": "Código de salida",
             "logs.filter-error": "No se pudo aplicar la expresión del filtro.",
             "logs.filter-error": "No se pudo aplicar la expresión del filtro.",
             "logs.filter-help-examples": "Ejemplos: backup · !Update · Status != Completed · Status == Blocked · Action contains backup and Status == Completed",
             "logs.filter-help-examples": "Ejemplos: backup · !Update · Status != Completed · Status == Blocked · Action contains backup and Status == Completed",
@@ -324,6 +327,7 @@
             "logs.clear-date-filter": "Cancella filtro data",
             "logs.clear-date-filter": "Cancella filtro data",
             "logs.clear-filter": "Cancella filtro di ricerca",
             "logs.clear-filter": "Cancella filtro di ricerca",
             "logs.completed": "Completato",
             "logs.completed": "Completato",
+            "logs.execution-id": "ID esecuzione",
             "logs.exit-code": "Codice di uscita",
             "logs.exit-code": "Codice di uscita",
             "logs.filter-error": "Impossibile applicare l'espressione di filtro.",
             "logs.filter-error": "Impossibile applicare l'espressione di filtro.",
             "logs.filter-help-examples": "Esempi: backup · !Update · Status != Completed · Status == Blocked · Action contains backup and Status == Completed",
             "logs.filter-help-examples": "Esempi: backup · !Update · Status != Completed · Status == Blocked · Action contains backup and Status == Completed",
@@ -415,6 +419,7 @@
             "logs.clear-date-filter": "清除日期筛选器",
             "logs.clear-date-filter": "清除日期筛选器",
             "logs.clear-filter": "清除搜索筛选器",
             "logs.clear-filter": "清除搜索筛选器",
             "logs.completed": "完成",
             "logs.completed": "完成",
+            "logs.execution-id": "执行 ID",
             "logs.exit-code": "退出代码",
             "logs.exit-code": "退出代码",
             "logs.filter-error": "无法应用过滤表达式。",
             "logs.filter-error": "无法应用过滤表达式。",
             "logs.filter-help-examples": "示例:backup · !Update · Status != Completed · Status == Blocked · Action contains backup and Status == Completed",
             "logs.filter-help-examples": "示例:backup · !Update · Status != Completed · Status == Blocked · Action contains backup and Status == Completed",
@@ -506,6 +511,7 @@
             "logs.clear-date-filter": "清除日期過濾條件",
             "logs.clear-date-filter": "清除日期過濾條件",
             "logs.clear-filter": "清除搜尋過濾條件",
             "logs.clear-filter": "清除搜尋過濾條件",
             "logs.completed": "已完成",
             "logs.completed": "已完成",
+            "logs.execution-id": "執行 ID",
             "logs.exit-code": "結束代碼",
             "logs.exit-code": "結束代碼",
             "logs.filter-error": "無法套用過濾運算式。",
             "logs.filter-error": "無法套用過濾運算式。",
             "logs.filter-help-examples": "範例:backup · !Update · Status != Completed · Status == Blocked · Action contains backup and Status == Completed",
             "logs.filter-help-examples": "範例:backup · !Update · Status != Completed · Status == Blocked · Action contains backup and Status == Completed",

+ 1 - 0
lang/de-DE.yaml

@@ -27,6 +27,7 @@ translations:
   logs.no-logs-for-filter: Keine Protokolle entsprechen dem aktuellen Filter.
   logs.no-logs-for-filter: Keine Protokolle entsprechen dem aktuellen Filter.
   logs.timestamp: Zeitstempel
   logs.timestamp: Zeitstempel
   logs.action: Aktion
   logs.action: Aktion
+  logs.execution-id: Ausführungs-ID
   logs.metadata: Metadaten
   logs.metadata: Metadaten
   logs.status: Status
   logs.status: Status
   logs.no-logs-to-display: Es gibt keine Protokolle zu anzeigen.
   logs.no-logs-to-display: Es gibt keine Protokolle zu anzeigen.

+ 1 - 0
lang/en.yaml

@@ -27,6 +27,7 @@ translations:
   logs.no-logs-for-filter: No logs match the current filter.
   logs.no-logs-for-filter: No logs match the current filter.
   logs.timestamp: Timestamp
   logs.timestamp: Timestamp
   logs.action: Action
   logs.action: Action
+  logs.execution-id: Execution ID
   logs.metadata: Metadata
   logs.metadata: Metadata
   logs.status: Status
   logs.status: Status
   logs.no-logs-to-display: There are no logs to display.
   logs.no-logs-to-display: There are no logs to display.

+ 1 - 0
lang/es-ES.yaml

@@ -27,6 +27,7 @@ translations:
   logs.no-logs-for-filter: Ningún registro coincide con el filtro actual.
   logs.no-logs-for-filter: Ningún registro coincide con el filtro actual.
   logs.timestamp: Marca de tiempo
   logs.timestamp: Marca de tiempo
   logs.action: Acción
   logs.action: Acción
+  logs.execution-id: ID de ejecución
   logs.metadata: Metadatos
   logs.metadata: Metadatos
   logs.status: Estado
   logs.status: Estado
   logs.no-logs-to-display: No hay registros para mostrar.
   logs.no-logs-to-display: No hay registros para mostrar.

+ 1 - 0
lang/it-IT.yaml

@@ -27,6 +27,7 @@ translations:
   logs.no-logs-for-filter: Nessun registro corrisponde al filtro corrente.
   logs.no-logs-for-filter: Nessun registro corrisponde al filtro corrente.
   logs.timestamp: Date e ora
   logs.timestamp: Date e ora
   logs.action: Azione
   logs.action: Azione
+  logs.execution-id: ID esecuzione
   logs.metadata: Metadati
   logs.metadata: Metadati
   logs.status: Stato
   logs.status: Stato
   logs.no-logs-to-display: Non ci sono registri da mostrare.
   logs.no-logs-to-display: Non ci sono registri da mostrare.

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

@@ -27,6 +27,7 @@ translations:
   logs.no-logs-for-filter: 没有符合当前过滤条件的日志。
   logs.no-logs-for-filter: 没有符合当前过滤条件的日志。
   logs.timestamp: 时间戳
   logs.timestamp: 时间戳
   logs.action: 动作
   logs.action: 动作
+  logs.execution-id: 执行 ID
   logs.metadata: 元数据
   logs.metadata: 元数据
   logs.status: 状态
   logs.status: 状态
   logs.no-logs-to-display: 没有日志可显示。
   logs.no-logs-to-display: 没有日志可显示。

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

@@ -27,6 +27,7 @@ translations:
   logs.no-logs-for-filter: 沒有符合目前過濾條件的日誌。
   logs.no-logs-for-filter: 沒有符合目前過濾條件的日誌。
   logs.timestamp: 時間戳記
   logs.timestamp: 時間戳記
   logs.action: 動作
   logs.action: 動作
+  logs.execution-id: 執行 ID
   logs.metadata: 中繼資料
   logs.metadata: 中繼資料
   logs.status: 狀態
   logs.status: 狀態
   logs.no-logs-to-display: 目前沒有可顯示的日誌。
   logs.no-logs-to-display: 目前沒有可顯示的日誌。

+ 9 - 0
service/.golangci.yml

@@ -1,3 +1,12 @@
+# OliveTin Go static analysis is run via `make codestyle` (golangci-lint).
+#
+# This config replaces the former standalone tools:
+#   go vet ./...              -> govet (enable-all)
+#   gocyclo -over 4           -> gocyclo min-complexity: 5
+#   gocritic check ./...      -> gocritic
+#   errcheck, staticcheck, ...-> same-named golangci-lint linters
+#
+# Cyclomatic complexity over 4 is not permitted (min-complexity 5 flags >4).
 version: "2"
 version: "2"
 
 
 run:
 run:

+ 1 - 2
service/Makefile

@@ -29,7 +29,6 @@ compile: compile-armhf compile-x64-lin compile-x64-win
 
 
 codestyle: go-tools
 codestyle: go-tools
 	go fmt ./...
 	go fmt ./...
-	go vet ./...
 	golangci-lint run ./...
 	golangci-lint run ./...
 
 
 test: unittests
 test: unittests
@@ -55,7 +54,7 @@ find-flakey-tests-inf:
 go-tools:
 go-tools:
 	go install "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2"
 	go install "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2"
 
 
-.PHONY: unittests unittests-fast find-flakey-tests find-flakey-tests-inf
+.PHONY: codestyle go-tools unittests unittests-fast find-flakey-tests find-flakey-tests-inf
 
 
 go-tools-all:
 go-tools-all:
 	go install "github.com/bufbuild/buf/cmd/buf"
 	go install "github.com/bufbuild/buf/cmd/buf"

+ 1 - 4
service/scripts/find-flakey-tests-inf/Makefile

@@ -1,5 +1,2 @@
 codestyle:
 codestyle:
-	go fmt ./...
-	go vet ./...
-	gocyclo -over 4 .
-	gocritic check ./...
+	$(MAKE) -C ../.. codestyle

+ 1 - 2
var/tekton/gobuilder/Dockerfile

@@ -6,6 +6,5 @@ COPY go.mod /workspace/
 COPY go.sum /workspace/
 COPY go.sum /workspace/
 WORKDIR /workspace
 WORKDIR /workspace
 RUN go install -v "github.com/bufbuild/buf/cmd/buf"
 RUN go install -v "github.com/bufbuild/buf/cmd/buf"
-RUN go install -v "github.com/fzipp/gocyclo/cmd/gocyclo"
-RUN go install -v "github.com/go-critic/go-critic/cmd/gocritic"
+RUN go install -v "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.2"
 RUN go install -v "google.golang.org/protobuf/cmd/protoc-gen-go"
 RUN go install -v "google.golang.org/protobuf/cmd/protoc-gen-go"