Просмотр исходного кода

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

jamesread 1 день назад
Родитель
Сommit
23221aa687
53 измененных файлов с 1722 добавлено и 1029 удалено
  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):
   - From repo root: `cd service && make unittests`
 - 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):
-  - 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
 - 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.
 - Match existing formatting; avoid unrelated reformatting.
 - 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)
 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
 ./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;

+ 1 - 1
Makefile

@@ -79,4 +79,4 @@ config-tool:
 devcheck:
 	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'
 
-import 'femtocrank/style.css'
+import 'picocrank/styles.css'
 import 'femtocrank/dark.css'
 import './style.css'
 
@@ -18,6 +18,7 @@ import router from './resources/vue/router.js'
 import App from './resources/vue/App.vue'
 
 import { initWebsocket } from './js/websocket.js'
+import { applyThemeStyles, getStoredThemePreference } from './resources/vue/utils/themeLoader.js'
 import combinedTranslations from '../lang/combined_output.json'
 
 function getSelectedLanguage () {
@@ -118,6 +119,8 @@ async function main () {
   try {
     const i18nSettings = await initClient()
 
+    await applyThemeStyles(getStoredThemePreference())
+
     initWebsocket()
 
     setupVue(i18nSettings)

+ 90 - 20
frontend/package-lock.json

@@ -18,7 +18,7 @@
 				"@xterm/addon-web-links": "^0.12.0",
 				"@xterm/xterm": "^6.0.0",
 				"iconify-icon": "^3.0.2",
-				"picocrank": "^1.28.0",
+				"picocrank": "^1.29.0",
 				"standard": "^17.1.2",
 				"unplugin-vue-components": "^32.1.0",
 				"vite": "^8.2.2",
@@ -1450,21 +1450,21 @@
 			"license": "MIT"
 		},
 		"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",
 			"dependencies": {
-				"@vue/devtools-shared": "^8.1.5",
+				"@vue/devtools-shared": "^8.2.1",
 				"birpc": "^2.6.1",
 				"hookable": "^5.5.3",
 				"perfect-debounce": "^2.0.0"
 			}
 		},
 		"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"
 		},
 		"node_modules/@vue/reactivity": {
@@ -2012,10 +2012,11 @@
 			"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
 		},
 		"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": {
 			"version": "0.0.1",
@@ -3100,9 +3101,9 @@
 			"license": "MIT"
 		},
 		"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,
 			"funding": [
 				{
@@ -5130,9 +5131,9 @@
 			"license": "ISC"
 		},
 		"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",
 			"dependencies": {
 				"@hugeicons/core-free-icons": "^4.3.0",
@@ -5141,8 +5142,77 @@
 				"femtocrank": "^2.7.1",
 				"unplugin-vue-components": "^32.1.0",
 				"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": {

+ 1 - 1
frontend/package.json

@@ -32,7 +32,7 @@
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/xterm": "^6.0.0",
 		"iconify-icon": "^3.0.2",
-		"picocrank": "^1.28.0",
+		"picocrank": "^1.29.0",
 		"standard": "^17.1.2",
 		"unplugin-vue-components": "^32.1.0",
 		"vite": "^8.2.2",

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

@@ -1,176 +1,179 @@
 <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
-              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>
 
-  <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>
-  </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>
-  </dialog>
+    </dialog>
+  </div>
 </template>
 
 <script setup>
@@ -187,6 +190,7 @@ import logoUrl from '../../OliveTinLogo.png'
 import { useI18n } from 'vue-i18n'
 import combinedTranslations from '../../../lang/combined_output.json'
 import { searchIndexItems, clearSearchIndex, indexSystemNavigation, indexSearchHints, indexRootDashboardEntries } from './stores/searchIndex.js'
+import { applyThemeStyles } from './utils/themeLoader.js'
 const { t } = useI18n()
 
 const router = useRouter()
@@ -302,7 +306,7 @@ function goToUserControlPanel () {
   router.push({ name: 'UserInformation' })
 }
 
-function updateHeaderFromInit () {
+async function updateHeaderFromInit () {
   if (!window.initResponse) {
     return
   }
@@ -336,7 +340,7 @@ function updateHeaderFromInit () {
   loadCustomJsIfEnabled()
 
   renderNavigation()
-  applyTheme()
+  await applyTheme()
 
   if (loginRequired.value) {
     connectEventStreamIfNeeded()
@@ -465,8 +469,31 @@ function addSystemNavLinks () {
   navigation.value.addSection(t('nav.system'))
 
   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 () {
@@ -533,7 +560,7 @@ function closeThemeDialog () {
   }
 }
 
-function changeTheme () {
+async function changeTheme () {
   if (!selectedTheme.value || selectedTheme.value === '') {
     localStorage.removeItem('olivetin-theme')
     themePreference.value = ''
@@ -542,25 +569,20 @@ function changeTheme () {
     themePreference.value = selectedTheme.value
   }
 
-  applyTheme()
+  await applyTheme()
   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
 
-onMounted(() => {
-  updateHeaderFromInit()
+onMounted(async () => {
+  await updateHeaderFromInit()
 
   // Initialize selected language from stored preference
   selectedLanguage.value = languagePreference.value

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

@@ -47,7 +47,8 @@
         class="back-button-container"
       >
         <button
-          class="back-button"
+          type="button"
+          class="back-button inline-icon"
           @click="goBack"
         >
           <HugeiconsIcon
@@ -55,7 +56,7 @@
             width="1.2em"
             height="1.2em"
           />
-          <span>Back</span>
+          Back
         </button>
       </div>
       <h2>{{ dashboard.title }}</h2>
@@ -76,7 +77,8 @@
         class="back-button-container"
       >
         <button
-          class="back-button"
+          type="button"
+          class="back-button inline-icon"
           @click="goBack"
         >
           <HugeiconsIcon
@@ -84,7 +86,7 @@
             width="1.2em"
             height="1.2em"
           />
-          <span>Back</span>
+          Back
         </button>
       </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>
-  <Section :padding="!hasTable">
+  <Section
+    :icon="CellsIcon"
+    :padding="!hasTable"
+  >
     <template #title>
       <span class="section-title-with-icon">
         Entity:
@@ -79,6 +82,7 @@
 
 <script setup>
 import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue'
+import { CellsIcon } from '@hugeicons/core-free-icons'
 import Section from 'picocrank/vue/components/Section.vue'
 import ActionIconGlyph from './ActionIconGlyph.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'),
     meta: {
       title: 'Logs Calendar',
-      breadcrumb: [
-        { name: 'Logs', href: '/logs' },
+      breadcrumbs: () => [
+        { name: 'Logs', to: { name: 'Logs' } },
         { name: 'Calendar' }
       ]
     }
@@ -52,8 +52,8 @@ const routes = [
     component: () => import('./views/LogsQueueView.vue'),
     meta: {
       title: 'Execution Queue',
-      breadcrumb: [
-        { name: 'Logs', href: '/logs' },
+      breadcrumbs: () => [
+        { name: 'Logs', to: { name: 'Logs' } },
         { name: 'Queue' }
       ]
     }
@@ -74,8 +74,8 @@ const routes = [
     props: true,
     meta: {
       title: 'OliveTin - Entity Details',
-      breadcrumb: [
-        { name: 'Entities', href: '/entities' },
+      breadcrumbs: () => [
+        { name: 'Entities', to: { name: 'Entities' } },
         { name: 'Entity Details' }
       ]
     }
@@ -87,8 +87,8 @@ const routes = [
     props: true,
     meta: {
       title: 'Execution',
-      breadcrumb: [
-        { name: 'Logs', href: '/logs' },
+      breadcrumbs: () => [
+        { name: 'Logs', to: { name: 'Logs' } },
         { name: 'Execution' }
       ]
     }
@@ -100,8 +100,8 @@ const routes = [
     props: true,
     meta: {
       title: 'Action Details',
-      breadcrumb: [
-        { name: 'Actions', href: '/' },
+      breadcrumbs: () => [
+        { name: 'Actions', to: { name: 'Actions' } },
         { name: 'Action Details' }
       ]
     }
@@ -113,8 +113,8 @@ const routes = [
     props: true,
     meta: {
       title: 'Execution conditions',
-      breadcrumb: [
-        { name: 'Actions', href: '/' },
+      breadcrumbs: () => [
+        { name: 'Actions', to: { name: 'Actions' } },
         { name: 'Execution conditions' }
       ]
     }
@@ -171,6 +171,10 @@ router.beforeEach((to) => {
 
 // Navigation guard for authentication (if needed)
 router.beforeEach((to) => {
+  if (window.initResponse?.loginRequired && to.name !== 'Login') {
+    return '/login'
+  }
+
   const isAuthenticated = window.isAuthenticated ?? false
 
   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>
-  <Section :padding="false">
+  <Section
+    :icon="PlayIcon"
+    :padding="false"
+  >
     <template #title>
       <span class="section-title-with-icon">
         Action Details:
@@ -12,35 +15,33 @@
       </span>
     </template>
     <template #toolbar>
-      <div class="action-details-toolbar">
-        <button
-          v-for="dashboard in backToDashboards"
-          :key="dashboard.path"
-          :title="'Back to ' + dashboard.title"
-          class="button neutral"
-          @click="goToDashboard(dashboard.path)"
-        >
-          <HugeiconsIcon :icon="DashboardSquare01Icon" />
-          {{ dashboard.title }}
-        </button>
-        <button
-          v-if="action"
-          title="Run this action"
-          class="button neutral"
-          @click="startAction"
-        >
-          <HugeiconsIcon :icon="WorkoutRunIcon" />
-          Run
-        </button>
-        <router-link
-          v-if="action"
-          :to="{ name: 'ActionExecConditions', params: { actionId: route.params.actionId } }"
-          class="button neutral"
-          title="View configured automatic triggers and on-demand execution"
-        >
-          Execution conditions ({{ executionConditionCount }})
-        </router-link>
-      </div>
+      <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>
 
     <div
@@ -129,67 +130,20 @@
     </div>
 
     <div v-show="filteredLogs.length > 0">
-      <table class="logs-table row-hover">
-        <thead>
-          <tr>
-            <th>Timestamp</th>
-            <th>Duration</th>
-            <th>Execution ID</th>
-            <th>Metadata</th>
-            <th>Status</th>
-          </tr>
-        </thead>
-        <tbody>
-          <tr
-            v-for="log in filteredLogs"
-            :key="log.executionTrackingId"
-            class="log-row"
-            :title="log.actionTitle"
-          >
-            <td class="timestamp">
-              {{ formatTimestamp(log.datetimeStarted) }}
-            </td>
-            <td class="duration">
-              {{ formatExecutionDuration(log) }}
-            </td>
-            <td>
-              <router-link :to="`/logs/${log.executionTrackingId}`">
-                <LogActionTitle :justification="log.justification">
-                  {{ log.executionTrackingId }}
-                </LogActionTitle>
-              </router-link>
-            </td>
-            <td class="tags">
-              <span class="annotation">
-                <span class="annotation-key">User:</span>
-                <span class="annotation-val">{{ log.user }}</span>
-              </span>
-              <span
-                v-if="log.tags && log.tags.length > 0"
-                class="tag-list"
-              >
-                <span
-                  v-for="tag in log.tags"
-                  :key="tag"
-                  class="tag"
-                >{{ tag }}</span>
-              </span>
-            </td>
-            <td class="exit-code">
-              <ActionStatusDisplay
-                :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
         :page-size="pageSize"
         :total="totalCount"
         :current-page="currentPage"
-        :page="currentPage"
         class="padding"
         item-title="execution logs"
         @page-change="handlePageChange"
@@ -215,11 +169,10 @@ import { useRoute, useRouter } from 'vue-router'
 import Pagination from 'picocrank/vue/components/Pagination.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
-import ActionStatusDisplay from '../components/ActionStatusDisplay.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 { DashboardSquare01Icon, WorkoutRunIcon } from '@hugeicons/core-free-icons'
+import { DashboardSquare01Icon, PlayIcon, WorkoutRunIcon } from '@hugeicons/core-free-icons'
 import { requestReconnectNow } from '../../../js/websocket.js'
 import { needsArgumentForm } from '../utils/needsArgumentForm.js'
 import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
@@ -320,16 +273,6 @@ function clearSearch () {
   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) {
   return n === 1 ? `1 ${singular}` : `${n} ${pluralForm}`
 }
@@ -483,39 +426,6 @@ onUnmounted(() => {
   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 {
   padding: 2rem;
   text-align: center;
@@ -599,13 +509,6 @@ onUnmounted(() => {
   padding: 1rem;
 }
 
-.action-details-toolbar {
-  display: inline-flex;
-  flex-wrap: wrap;
-  gap: 0.5rem;
-  align-items: center;
-}
-
 .action-group-list {
   margin: 0;
   padding-left: 0;

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

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

+ 63 - 76
frontend/resources/vue/views/DiagnosticsView.vue

@@ -1,5 +1,8 @@
 <template>
-  <Section :title="t('diagnostics.config-issues')">
+  <Section
+    :title="t('diagnostics.config-issues')"
+    :icon="AlertCircleIcon"
+  >
     <p>{{ t('diagnostics.config-issues-description') }}</p>
 
     <p v-if="!loading && configIssues.length === 0">
@@ -21,7 +24,10 @@
     </Table>
   </Section>
 
-  <Section :title="t('diagnostics.get-support')">
+  <Section
+    :title="t('diagnostics.get-support')"
+    :icon="CustomerSupportIcon"
+  >
     <p>
       {{ t('diagnostics.get-support-description') }}
     </p>
@@ -35,7 +41,10 @@
     </ul>
   </Section>
 
-  <Section :title="t('diagnostics.ssh')">
+  <Section
+    :title="t('diagnostics.ssh')"
+    :icon="ComputerTerminal01Icon"
+  >
     <dl>
       <dt>{{ t('diagnostics.found-key') }}</dt>
       <dd>{{ diagnostics.sshFoundKey || '?' }}</dd>
@@ -44,7 +53,10 @@
     </dl>
   </Section>
 
-  <Section :title="t('diagnostics.server-diagnostics')">
+  <Section
+    :title="t('diagnostics.server-diagnostics')"
+    :icon="CloudServerIcon"
+  >
     <p>{{ t('diagnostics.server-diagnostics-description') }}</p>
     <p>
       <a
@@ -53,60 +65,63 @@
       >{{ t('diagnostics.server-diagnostics-docs') }}</a>
     </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"
-      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 :title="t('diagnostics.browser-info')">
+  <Section
+    :title="t('diagnostics.browser-info')"
+    :icon="BrowserIcon"
+  >
     <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"
-      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>
 </template>
 
 <script setup>
 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 Table from 'picocrank/vue/components/Table.vue'
 import { useI18n } from 'vue-i18n'
@@ -118,8 +133,6 @@ const configIssues = ref([])
 const loading = ref(false)
 const serverDiagnostics = ref('')
 const browserInfo = ref('')
-const serverDiagnosticsCopied = ref(false)
-const browserInfoCopied = ref(false)
 
 const configIssueHeaders = computed(() => [
   { key: 'severity', label: t('diagnostics.config-issue-severity'), sortable: true, width: '7rem' },
@@ -168,7 +181,7 @@ async function generateServerDiagnostics () {
   try {
     const response = await window.client.serverDiagnostics()
     console.log('response', response)
-    serverDiagnostics.value = `\`\`\`\n${response.alert}\n\`\`\`\n`
+    serverDiagnostics.value = response.alert
   } catch (err) {
     console.error('Failed to generate server diagnostics:', err)
     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 () {
   loading.value = true
   try {
@@ -256,7 +257,6 @@ async function generateBrowserInfo () {
     const currentLanguage = locale.value || t('diagnostics.unknown')
 
     let output = ''
-    output += '```\n'
     output += '### BROWSER INFO START (copy all text to BROWSER INFO END)\n'
     output += '# OliveTin Information\n'
     output += `olivetinVersion: ${olivetinVersion}\n`
@@ -287,7 +287,6 @@ async function generateBrowserInfo () {
     output += `timezone: ${info.timezone}\n`
     output += `timezoneOffset: ${info.timezoneOffset}\n`
     output += '\n### BROWSER INFO END (copy all text from BROWSER INFO START)'
-    output += '\n```\n'
 
     browserInfo.value = output
   } 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(() => {
   fetchDiagnostics()
   window.addEventListener('EventConfigChanged', fetchDiagnostics)

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

@@ -2,10 +2,12 @@
   <Section
     v-if="!definitionsLoaded"
     title="Loading entity definitions..."
+    :icon="CellsIcon"
   />
   <Section
     v-else-if="totalInstances === 0"
     title="There are no entities to show yet."
+    :icon="CellsIcon"
   >
     <p>
       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>
 import { ref, computed, onMounted } from 'vue'
+import { CellsIcon } from '@hugeicons/core-free-icons'
 import Section from 'picocrank/vue/components/Section.vue'
 import EntityDefinitionSection from '../components/EntityDefinitionSection.vue'
 const definitionsLoaded = ref(false)

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

@@ -1,5 +1,5 @@
 <template>
-  <Section>
+  <Section :icon="CellsIcon">
     <template #title>
       <span class="section-title-with-icon">
         Entity Details:
@@ -13,7 +13,9 @@
     </template>
     <template #toolbar>
       <button
-        class="back-button"
+        type="button"
+        class="button neutral inline-icon"
+        title="Back"
         @click="goBack"
       >
         <HugeiconsIcon
@@ -21,7 +23,7 @@
           width="1.2em"
           height="1.2em"
         />
-        <span>Back</span>
+        Back
       </button>
     </template>
     <div v-if="!entityDetails">
@@ -63,6 +65,7 @@
   <Section
     v-if="entityDetails"
     title="Dashboard Entity Directories"
+    :icon="Folder01Icon"
   >
     <div
       v-if="filteredDirectories.length > 0"
@@ -126,7 +129,7 @@
 import { ref, computed, onMounted, onUnmounted } from 'vue'
 import { useRouter } from 'vue-router'
 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 ActionButton from '../ActionButton.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
@@ -182,25 +185,6 @@ onUnmounted(() => {
 </script>
 
 <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 {
     text-decoration: none;
     padding: 0.5em;
@@ -243,15 +227,6 @@ fieldset {
 }
 
 @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 {
         background-color: var(--bg-hover, #222);
     }

+ 32 - 8
frontend/resources/vue/views/ExecutionView.vue

@@ -1,5 +1,8 @@
 <template>
-  <Section id="execution-results-popup">
+  <Section
+    id="execution-results-popup"
+    :icon="ComputerTerminal01Icon"
+  >
     <template #title>
       <span class="section-title-with-icon">
         Execution Results:
@@ -35,7 +38,7 @@
         v-for="dashboard in backToDashboards"
         :key="dashboard.path"
         :title="'Back to ' + dashboard.title"
-        class="button neutral"
+        class="button neutral inline-icon"
         @click="goToDashboard(dashboard.path)"
       >
         <HugeiconsIcon :icon="DashboardSquare01Icon" />
@@ -44,7 +47,7 @@
       <button
         v-if="backToDashboards.length === 0"
         title="Go back"
-        class="button neutral"
+        class="button neutral inline-icon"
         @click="goBack"
       >
         <HugeiconsIcon :icon="ArrowLeftIcon" />
@@ -122,6 +125,8 @@
       <div class="fg1" />
 
       <button
+        type="button"
+        class="inline-icon"
         :disabled="!canRerun"
         title="Rerun"
         @click="rerunAction"
@@ -131,6 +136,8 @@
       </button>
       <button
         id="execution-dialog-kill-action"
+        type="button"
+        class="inline-icon"
         :disabled="!canKill"
         title="Kill"
         @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) {
   logEntry.value = logEntryParam
   if (logEntry.value == null) {
@@ -368,14 +389,17 @@ function updateDuration (logEntryParam) {
   } else {
     let delta = ''
     try {
-		  delta = (new Date(logEntry.value.datetimeFinished) - new Date(logEntry.value.datetimeStarted)) / 1000
-	  delta = new Intl.RelativeTimeFormat().format(delta, 'seconds').replace('in ', '').replace('ago', '')
+      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) {
-	  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 !== '') {
-	  duration.value += ' (' + delta + ')'
+      duration.value += ' (' + delta + ')'
     }
   }
 }

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

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

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

@@ -1,12 +1,13 @@
 <template>
   <Section
     :title="t('logs.calendar-title')"
+    :icon="Calendar01Icon"
     :padding="false"
   >
     <template #toolbar>
       <router-link
         :to="logsListLocation"
-        class="button neutral"
+        class="button neutral inline-icon"
       >
         <svg
           xmlns="http://www.w3.org/2000/svg"
@@ -42,6 +43,7 @@
 import { ref, computed, onMounted } from 'vue'
 import { useRouter } from 'vue-router'
 import { useI18n } from 'vue-i18n'
+import { Calendar01Icon } from '@hugeicons/core-free-icons'
 import Calendar from 'picocrank/vue/components/Calendar.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 import { loadStoredLogsFilter } from '../utils/logsFilterStorage.js'
@@ -177,16 +179,4 @@ onMounted(() => {
 .padding {
   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>

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

@@ -1,19 +1,22 @@
 <template>
   <Section
     :title="t('logs.title')"
+    :icon="LeftToRightListDashIcon"
     :padding="false"
   >
     <template #toolbar>
       <router-link
         to="/logs/queue"
-        class="button neutral"
+        class="button neutral inline-icon"
       >
+        <HugeiconsIcon :icon="Queue01Icon" />
         {{ t('logs.queue') }}
       </router-link>
       <router-link
         to="/logs/calendar"
-        class="button neutral"
+        class="button neutral inline-icon"
       >
+        <HugeiconsIcon :icon="Calendar01Icon" />
         {{ t('logs.calendar') }}
       </router-link>
       <label class="input-with-icons">
@@ -76,95 +79,44 @@
       >
         {{ filterError }}
       </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 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
         :page-size="pageSize"
         :total="totalCount"
         :current-page="currentPage"
-        :page="currentPage"
         class="padding"
         item-title="execution logs"
         @page-change="handlePageChange"
@@ -217,9 +169,9 @@ import { ConnectError, Code } from '@connectrpc/connect'
 import Pagination from 'picocrank/vue/components/Pagination.vue'
 import Section from 'picocrank/vue/components/Section.vue'
 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 { loadStoredLogsFilter, storeLogsFilter } from '../utils/logsFilterStorage.js'
 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) {
   currentPage.value = page
   fetchLogs()
@@ -475,20 +417,12 @@ onUnmounted(() => {
   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 {
@@ -506,47 +440,4 @@ onUnmounted(() => {
   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>

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

@@ -1,12 +1,13 @@
 <template>
   <Section
     :title="t('logs.queue-title')"
+    :icon="Queue01Icon"
     :padding="false"
   >
     <template #toolbar>
       <router-link
         to="/logs"
-        class="button neutral"
+        class="button neutral inline-icon"
       >
         <svg
           xmlns="http://www.w3.org/2000/svg"
@@ -58,90 +59,21 @@
     </div>
 
     <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>
   </section>
 </template>
 
 <script setup>
 import { ref, onMounted, onUnmounted } from 'vue'
+import { Queue01Icon } from '@hugeicons/core-free-icons'
 import Section from 'picocrank/vue/components/Section.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 ExecutionLogsTable from '../components/ExecutionLogsTable.vue'
 import { useI18n } from 'vue-i18n'
 import { getExecutionLogEntry, cloneLogEntry, updateLogEntryInGroups } from '../utils/executionLogEvents.js'
 
@@ -350,15 +282,22 @@ function onExecutionFinished (evt) {
   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 () {
@@ -399,38 +338,6 @@ onUnmounted(() => {
   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 {
   text-align: center;
   padding: 2rem;

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

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

+ 1 - 16
frontend/style.css

@@ -1,20 +1,5 @@
 @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 {
 	border-radius: 1em;
 }
@@ -62,4 +47,4 @@ section.small {
 
 .sm-transparent-footer footer span {
 	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'
 
 export default defineConfig({
+  resolve: {
+    dedupe: ['vue', 'vue-router'],
+  },
   plugins: [
     Components({
       dirs: ['resources/vue/'],

+ 162 - 71
integration-tests/lib/elements.js

@@ -4,6 +4,7 @@ import { expect } from 'chai'
 import { Condition } from 'selenium-webdriver'
 
 export const DEFAULT_UI_WAIT_MS = 3000
+const FAST_POLL_MS = 50
 
 // Keep Selenium helpers in lockstep with the frontend DOM id helpers.
 export {
@@ -15,6 +16,67 @@ export {
 } from '../../frontend/resources/vue/utils/argumentFieldIds.js'
 
 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 () {
   // Currently, only the active dashboard's contents are rendered,
@@ -23,10 +85,10 @@ export async function getActionButtons () {
 }
 
 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' })
       return await dialog.isDisplayed()
-    }));
+    })
 
     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) {
-  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 === '') {
       return false
@@ -75,118 +134,133 @@ export async function waitForDashboardLoaded(timeoutMs = DEFAULT_UI_WAIT_MS, exp
     }
 
     return true
-  }), timeoutMs)
+  }, timeoutMs)
 }
 
 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()
     return url.includes('/logs/') && !url.endsWith('/logs')
-  }), timeoutMs)
+  }, timeoutMs)
 }
 
 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()
     return url.includes('/actionBinding/') && url.includes('/argumentForm')
-  }), timeoutMs)
+  }, timeoutMs)
 }
 
 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 !== ''
-  }), timeoutMs)
+  }, timeoutMs)
 }
 
 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)
-    return statusElements.length > 0
-  }), timeoutMs)
+    if (statusElements.length === 0) {
+      return false
+    }
 
-  await webdriver.wait(new Condition('wait for execution to finish', async () => {
     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')
-    } catch (e) {
+    } catch {
       return false
     }
-  }), timeoutMs)
+  }, timeoutMs)
 }
 
 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()
+  loadedPageGeneration = runner.pageGeneration
+}
+
+async function isSidebarVisible () {
+  return isSidebarVisibleInBrowser()
 }
 
 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) {
-  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 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) {
@@ -215,3 +289,20 @@ export async function getTerminalBuffer() {
     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 () {
   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()
+  await global.webdriver.manage().setTimeouts({ implicit: 0, pageLoad: 30000, script: 30000 })
 
   global.runner = getRunner()
 

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

@@ -13,9 +13,9 @@
       },
       "devDependencies": {
         "chai": "^6.2.2",
-        "eslint": "^10.9.1",
+        "eslint": "^10.10.0",
         "mocha": "^12.0.0",
-        "selenium-webdriver": "^4.48.0"
+        "selenium-webdriver": "^4.49.0"
       }
     },
     "node_modules/@aashutoshrathi/word-wrap": {
@@ -34,6 +34,30 @@
       "dev": true,
       "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": {
       "version": "4.9.0",
       "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
@@ -128,9 +152,9 @@
       }
     },
     "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,
       "license": "Apache-2.0",
       "dependencies": {
@@ -190,41 +214,41 @@
       }
     },
     "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,
       "license": "Apache-2.0",
+      "dependencies": {
+        "@humanfs/types": "^0.15.0"
+      },
       "engines": {
         "node": ">=18.18.0"
       }
     },
     "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,
       "license": "Apache-2.0",
       "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": {
         "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,
       "license": "Apache-2.0",
       "engines": {
-        "node": ">=18.18"
-      },
-      "funding": {
-        "type": "github",
-        "url": "https://github.com/sponsors/nzakas"
+        "node": ">=18.18.0"
       }
     },
     "node_modules/@humanwhocodes/module-importer": {
@@ -254,6 +278,30 @@
         "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": {
       "version": "1.1.0",
       "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -387,6 +435,20 @@
       "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
       "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": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
@@ -573,9 +635,9 @@
       }
     },
     "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,
       "license": "MIT",
       "workspaces": [
@@ -587,7 +649,7 @@
         "@eslint/config-array": "^0.23.5",
         "@eslint/config-helpers": "^0.7.0",
         "@eslint/core": "^1.2.1",
-        "@eslint/plugin-kit": "^0.7.2",
+        "@eslint/plugin-kit": "^0.7.3",
         "@humanfs/node": "^0.16.6",
         "@humanwhocodes/module-importer": "^1.0.1",
         "@humanwhocodes/retry": "^0.4.2",
@@ -602,7 +664,7 @@
         "esquery": "^1.7.0",
         "esutils": "^2.0.2",
         "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",
         "glob-parent": "^6.0.2",
         "ignore": "^5.2.0",
@@ -748,16 +810,13 @@
       "dev": true
     },
     "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,
       "license": "MIT",
       "dependencies": {
-        "flat-cache": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=16.0.0"
+        "flat-cache": "^6.1.23"
       }
     },
     "node_modules/find-up": {
@@ -777,23 +836,21 @@
       }
     },
     "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,
       "license": "MIT",
       "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": {
-      "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,
       "license": "ISC"
     },
@@ -957,6 +1014,19 @@
         "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": {
       "version": "2.0.4",
       "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
@@ -969,6 +1039,13 @@
         "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": {
       "version": "5.0.1",
       "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
@@ -1070,9 +1147,9 @@
       "dev": true
     },
     "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",
       "dependencies": {
         "@hapi/address": "^5.1.1",
@@ -1110,13 +1187,6 @@
         "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": {
       "version": "0.4.1",
       "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
@@ -1143,13 +1213,13 @@
       }
     },
     "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,
       "license": "MIT",
       "dependencies": {
-        "json-buffer": "3.0.1"
+        "@keyv/serialize": "^1.1.1"
       }
     },
     "node_modules/levn": {
@@ -1456,6 +1526,26 @@
         "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": {
       "version": "2.3.8",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
@@ -1501,9 +1591,9 @@
       "dev": true
     },
     "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,
       "funding": [
         {

+ 2 - 2
integration-tests/package.json

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

+ 4 - 1
integration-tests/runner.mjs

@@ -21,6 +21,7 @@ export default function getRunner () {
 
 class OliveTinTestRunner {
   BASE_URL = 'http://nohost:1337/';
+  pageGeneration = 0
 
   baseUrl() {
     return this.BASE_URL
@@ -37,6 +38,8 @@ class OliveTinTestRunnerStartLocalProcess extends OliveTinTestRunner {
       await this.stop()
     }
 
+    this.pageGeneration += 1
+
     let stdout = ""
     let stderr = ""
 
@@ -137,7 +140,7 @@ class OliveTinTestRunnerStartLocalProcess extends OliveTinTestRunner {
       // GitHub runners seem to need a bit more time to clean up
       await new Promise((res) => setTimeout(res, 3000))
     } 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.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
     const loginElements = await webdriver.findElements(By.css('form.local-login-form, .login-oauth2, .login-disabled'))
     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 { expect } from 'chai'
-import { By } from 'selenium-webdriver'
+import { By, Condition } from 'selenium-webdriver'
 import {
+  DEFAULT_UI_WAIT_MS,
   getRootAndWait,
   takeScreenshotOnFailure,
 } 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 () {
   before(async function () {
     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 () {
     await getRootAndWait()
+    await waitForThemeCss()
 
     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')
 
+    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')
     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*\)/)
@@ -77,10 +112,16 @@ describe('config: cssClass', function () {
 
   it('custom theme applies background color to display component via cssClass', async function () {
     await getRootAndWait()
+    await waitForThemeCss()
 
     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')
 
+    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')
     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*\)/)

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

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

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

@@ -7,10 +7,14 @@ import {
   getActionButtons,
   openSidebar,
   getNavigationLinks,
+  getNavigationLinkTitle,
+  waitForNavigationLinks,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 
 describe('config: dashboards with basic fieldsets', function () {
+  this.timeout(30000)
+
   before(async function () {
     await runner.start('dashboardsWithBasicFieldsets')
   })
@@ -30,13 +34,14 @@ describe('config: dashboards with basic fieldsets', function () {
     expect(title).to.be.equal("Test - OliveTin")
 
     await openSidebar()
+    await waitForNavigationLinks(4)
 
     const navigationLinks = await getNavigationLinks()
     assert.equal(navigationLinks.length, 4, 'Expected the nav to only have 4 links') // test dashboard + entities + logs + diagnostics
 
     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()
     expect(actionButtons).to.have.length(5, 'Expected 5 action buttons')

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

@@ -6,10 +6,13 @@ import {
   getRootAndWait,
   openSidebar,
   getNavigationLinks,
+  waitForNavigationLinks,
   takeScreenshotOnFailure,
 } from '../../lib/elements.js'
 
 describe('config: empty dashboards are hidden', function () {
+  this.timeout(30000)
+
   before(async function () {
     await runner.start('emptyDashboardsAreHidden')
   })
@@ -26,6 +29,7 @@ describe('config: empty dashboards are hidden', function () {
     await getRootAndWait()
 
     await openSidebar()
+    await waitForNavigationLinks(3)
 
     const title = await webdriver.getTitle()
     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 { expect } from 'chai'
-import { By, until, Condition } from 'selenium-webdriver'
+import { By } from 'selenium-webdriver'
 //import * as waitOn from 'wait-on'
 import {
   getRootAndWait,
   getActionButtons,
   takeScreenshotOnFailure,
   openSidebar,
+  findSidebarNavHref,
+  waitForCurrentUrl,
+  waitForSelectorCount,
 } from '../../lib/elements.js'
 
 describe('config: general', function () {
@@ -34,8 +37,8 @@ describe('config: general', function () {
     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(diagnosticsLink).to.not.be.empty
@@ -50,10 +53,7 @@ describe('config: general', function () {
   it('Default buttons are rendered', async function() {
     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()
     expect(buttons.length).to.be.greaterThanOrEqual(4)
@@ -62,10 +62,7 @@ describe('config: general', function () {
   it('Start dir action (popup)', async function () {
     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"]'))
 
@@ -78,19 +75,13 @@ describe('config: general', function () {
     buttonCMD.click()
 
     // 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 () {
     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"]'))
 
@@ -102,7 +93,7 @@ describe('config: general', function () {
 
     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)
     const url = await webdriver.getCurrentUrl()
     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,
   getRootAndWait,
   getActionButtons,
+  getNavigationLinkTitles,
+  getNavigationLinkTitle,
   getNavigationLinks,
   openSidebar,
   takeScreenshotOnFailure,
@@ -34,7 +36,7 @@ describe('config: multi-dashboard-includes', function () {
 
     const matching = []
     for (const li of navigationLinks) {
-      const liTitle = await li.getAttribute('title')
+      const liTitle = await getNavigationLinkTitle(li)
       if (liTitle === title) {
         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}"`)
 
-    await matching[0].click()
+    const anchor = await matching[0].findElement(By.css('a[href]'))
+    await anchor.click()
     await waitForDashboardLoaded(DEFAULT_UI_WAIT_MS, title)
   }
 
@@ -61,13 +64,8 @@ describe('config: multi-dashboard-includes', function () {
     await getRootAndWait()
 
     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('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-filter": "Suchfilter löschen",
             "logs.completed": "Abgeschlossen",
+            "logs.execution-id": "Ausführungs-ID",
             "logs.exit-code": "Ausführungscode",
             "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",
@@ -142,6 +143,7 @@
             "logs.clear-date-filter": "Clear date filter",
             "logs.clear-filter": "Clear search filter",
             "logs.completed": "Completed",
+            "logs.execution-id": "Execution ID",
             "logs.exit-code": "Exit code",
             "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",
@@ -233,6 +235,7 @@
             "logs.clear-date-filter": "Limpiar filtro de fecha",
             "logs.clear-filter": "Limpiar filtro de búsqueda",
             "logs.completed": "Completado",
+            "logs.execution-id": "ID de ejecución",
             "logs.exit-code": "Código de salida",
             "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",
@@ -324,6 +327,7 @@
             "logs.clear-date-filter": "Cancella filtro data",
             "logs.clear-filter": "Cancella filtro di ricerca",
             "logs.completed": "Completato",
+            "logs.execution-id": "ID esecuzione",
             "logs.exit-code": "Codice di uscita",
             "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",
@@ -415,6 +419,7 @@
             "logs.clear-date-filter": "清除日期筛选器",
             "logs.clear-filter": "清除搜索筛选器",
             "logs.completed": "完成",
+            "logs.execution-id": "执行 ID",
             "logs.exit-code": "退出代码",
             "logs.filter-error": "无法应用过滤表达式。",
             "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-filter": "清除搜尋過濾條件",
             "logs.completed": "已完成",
+            "logs.execution-id": "執行 ID",
             "logs.exit-code": "結束代碼",
             "logs.filter-error": "無法套用過濾運算式。",
             "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.timestamp: Zeitstempel
   logs.action: Aktion
+  logs.execution-id: Ausführungs-ID
   logs.metadata: Metadaten
   logs.status: Status
   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.timestamp: Timestamp
   logs.action: Action
+  logs.execution-id: Execution ID
   logs.metadata: Metadata
   logs.status: Status
   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.timestamp: Marca de tiempo
   logs.action: Acción
+  logs.execution-id: ID de ejecución
   logs.metadata: Metadatos
   logs.status: Estado
   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.timestamp: Date e ora
   logs.action: Azione
+  logs.execution-id: ID esecuzione
   logs.metadata: Metadati
   logs.status: Stato
   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.timestamp: 时间戳
   logs.action: 动作
+  logs.execution-id: 执行 ID
   logs.metadata: 元数据
   logs.status: 状态
   logs.no-logs-to-display: 没有日志可显示。

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

@@ -27,6 +27,7 @@ translations:
   logs.no-logs-for-filter: 沒有符合目前過濾條件的日誌。
   logs.timestamp: 時間戳記
   logs.action: 動作
+  logs.execution-id: 執行 ID
   logs.metadata: 中繼資料
   logs.status: 狀態
   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"
 
 run:

+ 1 - 2
service/Makefile

@@ -29,7 +29,6 @@ compile: compile-armhf compile-x64-lin compile-x64-win
 
 codestyle: go-tools
 	go fmt ./...
-	go vet ./...
 	golangci-lint run ./...
 
 test: unittests
@@ -55,7 +54,7 @@ find-flakey-tests-inf:
 go-tools:
 	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 install "github.com/bufbuild/buf/cmd/buf"

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

@@ -1,5 +1,2 @@
 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/
 WORKDIR /workspace
 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"