Browse Source

Merge branch 'next' into feat-default-icon-cli-hugeicon

James Read 1 tháng trước cách đây
mục cha
commit
ae928625a6
35 tập tin đã thay đổi với 1148 bổ sung372 xóa
  1. 20 0
      .github/dependabot.yml
  2. 32 7
      .github/workflows/build-and-release.yml
  3. 10 4
      .github/workflows/codeql-analysis.yml
  4. 4 4
      .github/workflows/codestyle.yml
  5. 20 0
      .github/workflows/devskim.yml
  6. 2 0
      docs/modules/ROOT/nav.adoc
  7. 12 0
      docs/modules/ROOT/pages/action_customization/popuponstart.adoc
  8. 1 0
      docs/modules/ROOT/pages/advanced_configuration/webui.adoc
  9. 43 0
      docs/modules/ROOT/pages/dashboards/faq-display-hyperlinks.adoc
  10. 1 0
      docs/modules/ROOT/pages/dashboards/intro.adoc
  11. 66 0
      docs/modules/ROOT/pages/security/api_keys.adoc
  12. 2 0
      docs/modules/ROOT/pages/security/local.adoc
  13. 201 230
      frontend/package-lock.json
  14. 6 6
      frontend/package.json
  15. 8 1
      frontend/resources/vue/ActionButton.vue
  16. 84 41
      frontend/resources/vue/views/ActionDetailsView.vue
  17. 2 0
      frontend/resources/vue/views/ArgumentForm.vue
  18. 47 32
      frontend/resources/vue/views/EntitiesView.vue
  19. 20 20
      integration-tests/package-lock.json
  20. 3 3
      integration-tests/package.json
  21. 6 6
      service/go.mod
  22. 12 0
      service/go.sum
  23. 55 6
      service/internal/api/api.go
  24. 90 0
      service/internal/api/api_test.go
  25. 9 0
      service/internal/api/local_user_login.go
  26. 35 0
      service/internal/api/local_user_login_test.go
  27. 1 0
      service/internal/auth/authcheck.go
  28. 109 0
      service/internal/auth/local_bearer.go
  29. 106 0
      service/internal/auth/local_bearer_test.go
  30. 1 0
      service/internal/config/config.go
  31. 55 9
      service/internal/config/sanitize.go
  32. 36 1
      service/internal/config/sanitize_test.go
  33. 24 1
      service/internal/httpservers/frontend.go
  34. 17 0
      service/internal/httpservers/frontend_test.go
  35. 8 1
      service/internal/tpl/templates.go

+ 20 - 0
.github/dependabot.yml

@@ -10,6 +10,8 @@ updates:
     labels:
       - "3k"
       - "dependencies"
+    cooldown:
+      default-days: 7
 
   # npm updates for frontend - targeting "release/2k" branch (security updates only)
   - package-ecosystem: "npm"
@@ -21,6 +23,8 @@ updates:
     labels:
       - "2k"
       - "dependencies"
+    cooldown:
+      default-days: 7
 
   # npm updates for integration-tests - targeting "next" branch
   - package-ecosystem: "npm"
@@ -32,6 +36,8 @@ updates:
     labels:
       - "3k"
       - "dependencies"
+    cooldown:
+      default-days: 7
 
   # npm updates for integration-tests - targeting "release/2k" branch (security updates only)
   - package-ecosystem: "npm"
@@ -43,6 +49,8 @@ updates:
     labels:
       - "2k"
       - "dependencies"
+    cooldown:
+      default-days: 7
 
   # Go modules updates for service - targeting "next" branch
   - package-ecosystem: "gomod"
@@ -54,6 +62,8 @@ updates:
     labels:
       - "3k"
       - "dependencies"
+    cooldown:
+      default-days: 7
 
   # Go modules updates for service - targeting "release/2k" branch (security updates only)
   - package-ecosystem: "gomod"
@@ -65,6 +75,8 @@ updates:
     labels:
       - "2k"
       - "dependencies"
+    cooldown:
+      default-days: 7
 
   # Go modules updates for lang - targeting "next" branch
   - package-ecosystem: "gomod"
@@ -76,6 +88,8 @@ updates:
     labels:
       - "3k"
       - "dependencies"
+    cooldown:
+      default-days: 7
 
   # Go modules updates for lang - targeting "release/2k" branch (security updates only)
   - package-ecosystem: "gomod"
@@ -87,6 +101,8 @@ updates:
     labels:
       - "2k"
       - "dependencies"
+    cooldown:
+      default-days: 7
 
   # Docker updates - targeting "next" branch
   - package-ecosystem: "docker"
@@ -98,6 +114,8 @@ updates:
     labels:
       - "3k"
       - "dependencies"
+    cooldown:
+      default-days: 7
 
   # Docker updates - targeting "release/2k" branch (security updates only)
   - package-ecosystem: "docker"
@@ -109,4 +127,6 @@ updates:
     labels:
       - "2k"
       - "dependencies"
+    cooldown:
+      default-days: 7
 

+ 32 - 7
.github/workflows/build-and-release.yml

@@ -3,6 +3,16 @@ name: "Build & Release pipeline"
 
 on:
   pull_request:
+    paths:
+      - '.github/workflows/build-and-release.yml'
+      - '.goreleaser.yml'
+      - 'Dockerfile.multiarches'
+      - 'Dockerfile.singlearch'
+      - 'Makefile'
+      - 'frontend/**'
+      - 'integration-tests/**'
+      - 'proto/**'
+      - 'service/**'
   workflow_dispatch:
   push:
     tags:
@@ -11,31 +21,46 @@ on:
       - main
       - next
       - beta
+    paths:
+      - '.github/workflows/build-and-release.yml'
+      - '.goreleaser.yml'
+      - 'Dockerfile.multiarches'
+      - 'Dockerfile.singlearch'
+      - 'Makefile'
+      - 'frontend/**'
+      - 'integration-tests/**'
+      - 'proto/**'
+      - 'service/**'
 
 jobs:
   build:
     runs-on: ubuntu-latest
     steps:
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v6
         with:
           fetch-depth: 0
 
       - name: Set up QEMU
         id: qemu
-        uses: docker/setup-qemu-action@v3
+        uses: docker/setup-qemu-action@v4
         with:
           image: tonistiigi/binfmt:latest
           platforms: arm64,arm
 
-      - name: Setup node
-        uses: actions/setup-node@v4
+      - name: Setup node (npm cache)
+        if: github.event_name != 'pull_request'
+        uses: actions/setup-node@v6.4.0
         with:
           cache: 'npm'
           cache-dependency-path: frontend/package-lock.json
 
+      - name: Setup node
+        if: github.event_name == 'pull_request'
+        uses: actions/setup-node@v6.4.0
+
       - name: Setup Go
-        uses: actions/setup-go@v5
+        uses: actions/setup-go@v6
         with:
           go-version-file: 'service/go.mod'
           cache: true
@@ -45,7 +70,7 @@ jobs:
         run: go version
 
       - name: Login to Docker Hub
-        uses: docker/login-action@v3
+        uses: docker/login-action@v4
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_KEY }}
@@ -74,7 +99,7 @@ jobs:
         run: cd integration-tests && make -w
 
       - name: Archive integration tests
-        uses: actions/upload-artifact@v4.3.1
+        uses: actions/upload-artifact@v7
         if: always()
         with:
           name: "OliveTin-integration-tests-${{ env.DATE }}-${{ github.sha }}"

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

@@ -15,13 +15,19 @@ name: "CodeQL"
 on:
   push:
     paths:
-      - 'cmd/**'
-      - 'internal/**'
-      - 'webui.dev/**'
+      - '.github/workflows/codeql-analysis.yml'
+      - 'frontend/**'
       - 'integration-tests/**'
-      - 'OliveTin.proto'
+      - 'proto/**'
+      - 'service/**'
     branches: [main]
   pull_request:
+    paths:
+      - '.github/workflows/codeql-analysis.yml'
+      - 'frontend/**'
+      - 'integration-tests/**'
+      - 'proto/**'
+      - 'service/**'
     branches: [main]
   schedule:
     - cron: '25 10 * * 5'

+ 4 - 4
.github/workflows/codestyle.yml

@@ -4,11 +4,11 @@ name: "Codestyle checks"
 on:
   push:
     paths:
-      - 'cmd/**'
-      - 'internal/**'
-      - 'webui.dev/**'
+      - '.github/workflows/codestyle.yml'
+      - 'frontend/**'
       - 'integration-tests/**'
-      - 'OliveTin.proto'
+      - 'proto/**'
+      - 'service/**'
 
 
 jobs:

+ 20 - 0
.github/workflows/devskim.yml

@@ -7,8 +7,28 @@ name: DevSkim
 
 on:
   push:
+    paths:
+      - '.github/workflows/devskim.yml'
+      - '.goreleaser.yml'
+      - 'Dockerfile.multiarches'
+      - 'Dockerfile.singlearch'
+      - 'Makefile'
+      - 'frontend/**'
+      - 'integration-tests/**'
+      - 'proto/**'
+      - 'service/**'
     branches: [ "main" ]
   pull_request:
+    paths:
+      - '.github/workflows/devskim.yml'
+      - '.goreleaser.yml'
+      - 'Dockerfile.multiarches'
+      - 'Dockerfile.singlearch'
+      - 'Makefile'
+      - 'frontend/**'
+      - 'integration-tests/**'
+      - 'proto/**'
+      - 'service/**'
     branches: [ "main" ]
   schedule:
     - cron: '34 21 * * 2'

+ 2 - 0
docs/modules/ROOT/nav.adoc

@@ -86,6 +86,7 @@
 ** xref:dashboards/2-fieldsets.adoc[Fieldsets]
 ** xref:dashboards/3-folders.adoc[Folders]
 ** xref:dashboards/4-displays.adoc[Displays]
+** xref:dashboards/faq-display-hyperlinks.adoc[Hyperlinks (Displays)]
 ** xref:dashboards/5-output-views.adoc[Output Views]
 ** xref:dashboards/entity-directories.adoc[Entity Directories]
 * xref:entities/intro.adoc[Entities]
@@ -95,6 +96,7 @@
 * xref:security/concepts.adoc[Security]
 ** xref:security/acl.adoc[Access Control Lists]
 ** xref:security/local.adoc[Local Users Authorization]
+** xref:security/api_keys.adoc[API Keys]
 ** xref:security/trusted_header.adoc[Trusted Header Authorization]
 ** xref:security/jwt.adoc[JWT Authorization]
 *** xref:security/jwt_keys.adoc[JWT with Keys]

+ 12 - 0
docs/modules/ROOT/pages/action_customization/popuponstart.adoc

@@ -68,4 +68,16 @@ actions:
 
 image::../executionButtons.png[]
 
+== Action execution history
+
+The `history` option opens the action details page for that binding when the execution starts, so you can see past runs and status for the same action.
+
+[source,yaml]
+.`config.yaml`
+----
+actions:
+  - title: Long-running job
+    popupOnStart: history
+----
+
 

+ 1 - 0
docs/modules/ROOT/pages/advanced_configuration/webui.adoc

@@ -44,6 +44,7 @@ image::defaultUiHideNav.png[]
 When enabled (the default), each action button can show a small icon indicating what happens when the action is started:
 
 * **Popup dialog** — the action opens a popup (e.g. `popupOnStart: execution-dialog`)
+* **Action history** — the action opens the action details page (e.g. `popupOnStart: history`)
 * **Argument form** — the action opens an argument form on start
 * **Run in background** — the action runs without opening a dialog
 

+ 43 - 0
docs/modules/ROOT/pages/dashboards/faq-display-hyperlinks.adoc

@@ -0,0 +1,43 @@
+[#faq-dashboard-display-hyperlinks]
+= Hyperlinks in dashboards
+
+This page explains how to add clickable links on dashboards using xref:dashboards/4-displays.adoc[display components] (`type: display`).
+
+== How do I add a clickable link on a dashboard?
+
+Use a `type: display` component and put normal HTML in its `title` field. OliveTin renders `title` as HTML, so use an anchor element, for example:
+
+[source,html]
+----
+<a href="https://example.com">Documentation</a>
+----
+
+== Should I open external links in a new tab?
+
+For links that leave OliveTin, `target="_blank"` is convenient. Combine it with `rel="noopener noreferrer"` so the new page cannot access your OliveTin tab and referrer details are limited. Example:
+
+[source,yaml]
+----
+contents:
+  - type: display
+    title: |
+      <a href="https://example.com/docs" target="_blank" rel="noopener noreferrer">Open docs</a>
+----
+
+== Can I mix links with entity variables or plain text?
+
+Yes. `title` can combine literal text, xref:entities/intro.adoc[entity template variables], and HTML (such as `<br />`, `<strong>`, or `<a>`). Use YAML's `|` block scalar when you need several lines.
+
+== Does Markdown link syntax like `[text](url)` work?
+
+No. Display `title` is not run through a Markdown parser; it is treated as HTML. Use `<a href="...">...</a>` (or other tags you need) explicitly.
+
+== What should I avoid when embedding HTML?
+
+Treat anything you put in `title` as trusted markup only you control; do not paste untrusted strings into `href` or other attributes without strict validation.
+
+Some deployments use xref:security/content_security_policy.adoc[Content Security Policy] headers, which may block certain schemes or injected scripts—ordinary `https:` links are usually the safest choice.
+
+== Where else is this documented?
+
+See xref:dashboards/4-displays.adoc[Displays] for full display configuration (including xref:dashboards/css.adoc[CSS classes]).

+ 1 - 0
docs/modules/ROOT/pages/dashboards/intro.adoc

@@ -89,6 +89,7 @@ Now that you understand dashboards, explore these related features:
 * xref:dashboards/2-fieldsets.adoc[Learn about fieldsets] - Group actions visually on dashboards
 * xref:dashboards/3-folders.adoc[Organize with folders] - Create folder structures for better organization
 * xref:dashboards/4-displays.adoc[Add displays] - Show information alongside actions
+* xref:dashboards/faq-display-hyperlinks.adoc[FAQ: Hyperlinks in displays] - Clickable links in display components
 * xref:dashboards/5-output-views.adoc[Configure output views] - Customize how action output is displayed
 * xref:entities/intro.adoc[Use entities with dashboards] - Dynamically generate actions from entity files
 * xref:dashboards/examples.adoc[View dashboard examples] - See complete dashboard configurations

+ 66 - 0
docs/modules/ROOT/pages/security/api_keys.adoc

@@ -0,0 +1,66 @@
+[#api-keys]
+= API Keys
+
+This page is for **developers** who want to call OliveTin's HTTP API (Connect RPC under `/api/`) using a **Bearer token**, without using the interactive web login.
+
+API keys are configured on xref:security/local.adoc[local users] as an optional `apiKey` field. When present, clients can authenticate by sending:
+
+----
+Authorization: Bearer <your-api-key>
+----
+
+The prefix `Bearer ` (including the trailing space after `Bearer`) must match exactly.
+
+== Configuration
+
+include::partial$config-start.adoc[]
+----
+authLocalUsers:
+  enabled: true
+  users:
+    - username: automation
+      usergroup: bots
+      apiKey: "{{ .Env.OLIVETIN_AUTOMATION_KEY }}"
+
+    - username: alice
+      usergroup: admins
+      password: $argon2id$v=19$m=65536,t=4,p=6$...
+      apiKey: "{{ .Env.OLIVETIN_ALICE_API_KEY }}"
+----
+
+* Use a **long, random** API key (similar to any other bearer secret).
+* Prefer loading the key from the environment with `{{ .Env.VAR }}` instead of committing the raw value to disk.
+* **TLS**: send bearer tokens only over HTTPS in real deployments.
+* **Interactive login**: if a user has **no** `password` configured, they **cannot** use the `/login` page; they can only authenticate with an API key (or another auth mechanism you configure separately).
+
+Two local users **must not** share the same `apiKey` value. OliveTin will refuse to start if duplicate keys are detected.
+
+== Authorization (permissions)
+
+API key authentication uses the same **username** and **usergroup** as the matching local user. xref:security/acl.adoc[Access Control Lists] and `defaultPermissions` apply in the same way as for users who sign in via the web UI.
+
+== Example: curl and Init
+
+The OliveTin API is **Connect RPC**. Unary calls accept JSON bodies. The following example calls `Init` with an empty request object:
+
+[source,bash]
+----
+curl -sS -X POST \
+  -H "Authorization: Bearer YOUR_API_KEY_HERE" \
+  -H "Content-Type: application/json" \
+  "https://olivetin.example.com:1337/api/olivetin.api.v1.OliveTinApiService/Init" \
+  --data '{}'
+----
+
+Replace the host, port, and path prefix if your installation differs. Other RPCs use the same URL pattern with a different final segment (method name).
+
+== Operational security notes
+
+* **Reverse proxies**: if you use xref:security/trusted_header.adoc[Trusted Header Authorization], remember it is evaluated **before** bearer API keys. Do not expose OliveTin in a way that allows clients to spoof trusted identity headers.
+* **Debug logging**: avoid enabling `logDebugOptions.singleFrontendRequestHeaders` in production. OliveTin redacts common sensitive headers (including `Authorization`) in debug output, but minimizing debug surface area is still recommended.
+* **Brute force**: OliveTin does not ship per-IP rate limiting for failed bearer attempts. Consider rate limiting or WAF rules on `/api/` at your reverse proxy.
+
+== See also
+
+* xref:security/local.adoc[Local Users Authorization] (password hashing and local user basics)
+* xref:security/acl.adoc[Access Control Lists]

+ 2 - 0
docs/modules/ROOT/pages/security/local.adoc

@@ -3,6 +3,8 @@
 
 OliveTin supports just basic users defined with a username and password in the config.yaml file. This can be used when you do not want to use a full authentication system like LDAP, OAuth2 or a Reverse Proxy.
 
+For programmatic access (scripts, integrations) using per-user bearer API keys, see xref:security/api_keys.adoc[API Keys].
+
 == Define a user
 
 include::partial$config-start.adoc[]

+ 201 - 230
frontend/package-lock.json

@@ -11,9 +11,9 @@
 			"dependencies": {
 				"@connectrpc/connect": "^2.1.1",
 				"@connectrpc/connect-web": "^2.1.1",
-				"@hugeicons/core-free-icons": "^4.1.3",
+				"@hugeicons/core-free-icons": "^4.1.4",
 				"@hugeicons/vue": "^1.0.5",
-				"@vitejs/plugin-vue": "^6.0.6",
+				"@vitejs/plugin-vue": "^6.0.7",
 				"@xterm/addon-fit": "^0.11.0",
 				"@xterm/addon-web-links": "^0.12.0",
 				"@xterm/xterm": "^6.0.0",
@@ -21,14 +21,14 @@
 				"picocrank": "^1.15.0",
 				"standard": "^17.1.2",
 				"unplugin-vue-components": "^32.0.0",
-				"vite": "^8.0.12",
+				"vite": "^8.0.13",
 				"vue": "^3.5.34",
-				"vue-i18n": "^11.4.2",
-				"vue-router": "^5.0.6"
+				"vue-i18n": "^11.4.4",
+				"vue-router": "^5.0.7"
 			},
 			"devDependencies": {
 				"process": "^0.11.10",
-				"stylelint": "^17.11.0",
+				"stylelint": "^17.11.1",
 				"stylelint-config-standard": "^40.0.0"
 			}
 		},
@@ -48,19 +48,66 @@
 			}
 		},
 		"node_modules/@babel/generator": {
-			"version": "7.29.0",
-			"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz",
-			"integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==",
+			"version": "8.0.0-rc.5",
+			"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-8.0.0-rc.5.tgz",
+			"integrity": "sha512-nFZPWz3FHIS7y6rMIVoa/WBwjdutfIaRJIBQjzn+t3RnecZoRNlGmGcyR2wb0T/IgSd50Kz/6dG8/LvMCRunjg==",
 			"license": "MIT",
 			"dependencies": {
-				"@babel/parser": "^7.29.0",
-				"@babel/types": "^7.29.0",
+				"@babel/parser": "^8.0.0-rc.5",
+				"@babel/types": "^8.0.0-rc.5",
 				"@jridgewell/gen-mapping": "^0.3.12",
 				"@jridgewell/trace-mapping": "^0.3.28",
+				"@types/jsesc": "^2.5.0",
 				"jsesc": "^3.0.2"
 			},
 			"engines": {
-				"node": ">=6.9.0"
+				"node": "^22.18.0 || >=24.11.0"
+			}
+		},
+		"node_modules/@babel/generator/node_modules/@babel/helper-string-parser": {
+			"version": "8.0.0-rc.5",
+			"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-8.0.0-rc.5.tgz",
+			"integrity": "sha512-sN7R8rBvDurfaziNfDEIjIntlazmlkCDGO4SNl2RJ3wRCn+QxspLV7hzYAE8WWVd2joVuT8sUxeePdLp2idI1A==",
+			"license": "MIT",
+			"engines": {
+				"node": "^22.18.0 || >=24.11.0"
+			}
+		},
+		"node_modules/@babel/generator/node_modules/@babel/helper-validator-identifier": {
+			"version": "8.0.0-rc.5",
+			"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.0-rc.5.tgz",
+			"integrity": "sha512-ehJDxHvtbZ85RtX/L2fi0h9AGsBNqB5Euv1EB8RMAvGYvD+2X+QbpzzOpbklnNXO+WSZJNOaetw2BBj27xsWVg==",
+			"license": "MIT",
+			"engines": {
+				"node": "^22.18.0 || >=24.11.0"
+			}
+		},
+		"node_modules/@babel/generator/node_modules/@babel/parser": {
+			"version": "8.0.0-rc.5",
+			"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-8.0.0-rc.5.tgz",
+			"integrity": "sha512-/Mfg83rK3+jsRbl4Vbd0jqxc6M1A1/WNFtgrowRM1unEsD3XcNnrBdMM0JWakd0/RN9lseQKwPduW1TiEwKOlQ==",
+			"license": "MIT",
+			"dependencies": {
+				"@babel/types": "^8.0.0-rc.5"
+			},
+			"bin": {
+				"parser": "bin/babel-parser.js"
+			},
+			"engines": {
+				"node": "^22.18.0 || >=24.11.0"
+			}
+		},
+		"node_modules/@babel/generator/node_modules/@babel/types": {
+			"version": "8.0.0-rc.5",
+			"resolved": "https://registry.npmjs.org/@babel/types/-/types-8.0.0-rc.5.tgz",
+			"integrity": "sha512-JeSVu/m8x/zpp4CLjYHVNXuhEyOkhPXuxM8YOXjh6L4LlvQNKuUNOTo5KdBuKAcTDHw8DquToTaEkhsBqPXOaA==",
+			"license": "MIT",
+			"dependencies": {
+				"@babel/helper-string-parser": "^8.0.0-rc.5",
+				"@babel/helper-validator-identifier": "^8.0.0-rc.5"
+			},
+			"engines": {
+				"node": "^22.18.0 || >=24.11.0"
 			}
 		},
 		"node_modules/@babel/helper-string-parser": {
@@ -891,9 +938,9 @@
 			}
 		},
 		"node_modules/@hugeicons/core-free-icons": {
-			"version": "4.1.3",
-			"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.1.3.tgz",
-			"integrity": "sha512-FWPrKnlYKpSaitUtlZhFlDQXDgHiayTPFJYWvyIKkW2RI6Vj5KBvjxI+lAnnFPu07SwgIMiDDj+Gttl0t+o/oQ==",
+			"version": "4.1.4",
+			"resolved": "https://registry.npmjs.org/@hugeicons/core-free-icons/-/core-free-icons-4.1.4.tgz",
+			"integrity": "sha512-vkMvlnW7Atqh7juhZCadYDvqABWcEeVAkavAK6HJBFbYkLCme5CN51Oc91D5j5ruDja+HQPjMfWkTxxulcwvAQ==",
 			"license": "MIT"
 		},
 		"node_modules/@hugeicons/vue": {
@@ -947,61 +994,61 @@
 			"license": "MIT"
 		},
 		"node_modules/@intlify/core-base": {
-			"version": "11.4.2",
-			"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.2.tgz",
-			"integrity": "sha512-7fpuCcVmeLv2T9qHsARqGvh8xt+sV2fH+Q+gMHFwB/rPXzo85DpbJFKn7dBH1L5p0c2cSh2DW+2h/64EKrISmA==",
+			"version": "11.4.4",
+			"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.4.tgz",
+			"integrity": "sha512-w/vItlylrAmhebkIbVl5YY8XMCtj8Mb2g70ttxktMYuf5AuRahgEHL2iLgLIsZBIbTSgs4hkUo7ucCL0uTJvOg==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/devtools-types": "11.4.2",
-				"@intlify/message-compiler": "11.4.2",
-				"@intlify/shared": "11.4.2"
+				"@intlify/devtools-types": "11.4.4",
+				"@intlify/message-compiler": "11.4.4",
+				"@intlify/shared": "11.4.4"
 			},
 			"engines": {
-				"node": ">= 16"
+				"node": ">= 22"
 			},
 			"funding": {
 				"url": "https://github.com/sponsors/kazupon"
 			}
 		},
 		"node_modules/@intlify/devtools-types": {
-			"version": "11.4.2",
-			"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.2.tgz",
-			"integrity": "sha512-3u8EN1kB6EMSi96KXs5k7a8y2X2g4+h3X6iwVZU47cP4n+mTuq//WMjG588BzSp/2XQ/dTXo2BLUXX+XS+PNfA==",
+			"version": "11.4.4",
+			"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.4.tgz",
+			"integrity": "sha512-PcBLmGmDQsTSVV911P8upzpcLJO1CNVYi/IH6bGnLR2nA+0L963+kXN1ZrisTEnbtw2ewN6HMMSldqzjronA0Q==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/core-base": "11.4.2",
-				"@intlify/shared": "11.4.2"
+				"@intlify/core-base": "11.4.4",
+				"@intlify/shared": "11.4.4"
 			},
 			"engines": {
-				"node": ">= 16"
+				"node": ">= 22"
 			},
 			"funding": {
 				"url": "https://github.com/sponsors/kazupon"
 			}
 		},
 		"node_modules/@intlify/message-compiler": {
-			"version": "11.4.2",
-			"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.2.tgz",
-			"integrity": "sha512-a6CDSGSMTGrg0BjD97x8TBYPf7qQMDlZipJ6UDfv/pd4OIym8TMlHu3MsH0bTNnRdAG2D6EFEykIgiQPqvtTkA==",
+			"version": "11.4.4",
+			"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.4.tgz",
+			"integrity": "sha512-vn0OAV9pYkJlPPmgnsSm5eAG3mL0+9C/oaded2JY9jmxBbhmUXT3TcAUY8WRgLY9Hte7lkUJKpXrVlYjMXBD2w==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/shared": "11.4.2",
+				"@intlify/shared": "11.4.4",
 				"source-map-js": "^1.0.2"
 			},
 			"engines": {
-				"node": ">= 16"
+				"node": ">= 22"
 			},
 			"funding": {
 				"url": "https://github.com/sponsors/kazupon"
 			}
 		},
 		"node_modules/@intlify/shared": {
-			"version": "11.4.2",
-			"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.2.tgz",
-			"integrity": "sha512-NzpHbguRCsOHDwxmlBa9qu/imc+/QWgsYUaK6FZeNC0wK8QfAbhqrktEp/haVzxU1aikH8IX4ytD+mfFEMi/9A==",
+			"version": "11.4.4",
+			"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.4.tgz",
+			"integrity": "sha512-QRUCHqda1U6aR14FR0vvXD4+4gj6+fm0AhAozvSuRCw0fCvrmCugWpgiR4xH2NI6s8am6N9p5OhirplsX8ZS3g==",
 			"license": "MIT",
 			"engines": {
-				"node": ">= 16"
+				"node": ">= 22"
 			},
 			"funding": {
 				"url": "https://github.com/sponsors/kazupon"
@@ -1110,18 +1157,18 @@
 			}
 		},
 		"node_modules/@oxc-project/types": {
-			"version": "0.129.0",
-			"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
-			"integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
+			"version": "0.130.0",
+			"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
+			"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
 			"license": "MIT",
 			"funding": {
 				"url": "https://github.com/sponsors/Boshen"
 			}
 		},
 		"node_modules/@rolldown/binding-android-arm64": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
-			"integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
+			"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
 			"cpu": [
 				"arm64"
 			],
@@ -1135,9 +1182,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-darwin-arm64": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
-			"integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
+			"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
 			"cpu": [
 				"arm64"
 			],
@@ -1151,9 +1198,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-darwin-x64": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
-			"integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
+			"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
 			"cpu": [
 				"x64"
 			],
@@ -1167,9 +1214,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-freebsd-x64": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
-			"integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
+			"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
 			"cpu": [
 				"x64"
 			],
@@ -1183,9 +1230,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
-			"integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
+			"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
 			"cpu": [
 				"arm"
 			],
@@ -1199,9 +1246,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-arm64-gnu": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
-			"integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
+			"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
 			"cpu": [
 				"arm64"
 			],
@@ -1215,9 +1262,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-arm64-musl": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
-			"integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
+			"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
 			"cpu": [
 				"arm64"
 			],
@@ -1231,9 +1278,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-ppc64-gnu": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
-			"integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
+			"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
 			"cpu": [
 				"ppc64"
 			],
@@ -1247,9 +1294,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-s390x-gnu": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
-			"integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
+			"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
 			"cpu": [
 				"s390x"
 			],
@@ -1263,9 +1310,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-x64-gnu": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
-			"integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
+			"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
 			"cpu": [
 				"x64"
 			],
@@ -1279,9 +1326,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-linux-x64-musl": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
-			"integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
+			"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
 			"cpu": [
 				"x64"
 			],
@@ -1295,9 +1342,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-openharmony-arm64": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
-			"integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
+			"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
 			"cpu": [
 				"arm64"
 			],
@@ -1311,9 +1358,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-wasm32-wasi": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
-			"integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
+			"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
 			"cpu": [
 				"wasm32"
 			],
@@ -1329,9 +1376,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-win32-arm64-msvc": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
-			"integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
+			"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
 			"cpu": [
 				"arm64"
 			],
@@ -1345,9 +1392,9 @@
 			}
 		},
 		"node_modules/@rolldown/binding-win32-x64-msvc": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
-			"integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
+			"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
 			"cpu": [
 				"x64"
 			],
@@ -1361,9 +1408,9 @@
 			}
 		},
 		"node_modules/@rolldown/pluginutils": {
-			"version": "1.0.0-rc.13",
-			"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz",
-			"integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+			"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
 			"license": "MIT"
 		},
 		"node_modules/@rtsao/scc": {
@@ -1395,6 +1442,12 @@
 				"tslib": "^2.4.0"
 			}
 		},
+		"node_modules/@types/jsesc": {
+			"version": "2.5.1",
+			"resolved": "https://registry.npmjs.org/@types/jsesc/-/jsesc-2.5.1.tgz",
+			"integrity": "sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw==",
+			"license": "MIT"
+		},
 		"node_modules/@types/json5": {
 			"version": "0.0.29",
 			"resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz",
@@ -1408,12 +1461,12 @@
 			"license": "ISC"
 		},
 		"node_modules/@vitejs/plugin-vue": {
-			"version": "6.0.6",
-			"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz",
-			"integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==",
+			"version": "6.0.7",
+			"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz",
+			"integrity": "sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==",
 			"license": "MIT",
 			"dependencies": {
-				"@rolldown/pluginutils": "1.0.0-rc.13"
+				"@rolldown/pluginutils": "^1.0.1"
 			},
 			"engines": {
 				"node": "^20.19.0 || >=22.12.0"
@@ -1507,28 +1560,22 @@
 			"license": "MIT"
 		},
 		"node_modules/@vue/devtools-kit": {
-			"version": "8.0.6",
-			"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.0.6.tgz",
-			"integrity": "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw==",
+			"version": "8.1.2",
+			"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-8.1.2.tgz",
+			"integrity": "sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/devtools-shared": "^8.0.6",
+				"@vue/devtools-shared": "^8.1.2",
 				"birpc": "^2.6.1",
 				"hookable": "^5.5.3",
-				"mitt": "^3.0.1",
-				"perfect-debounce": "^2.0.0",
-				"speakingurl": "^14.0.1",
-				"superjson": "^2.2.2"
+				"perfect-debounce": "^2.0.0"
 			}
 		},
 		"node_modules/@vue/devtools-shared": {
-			"version": "8.0.6",
-			"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.0.6.tgz",
-			"integrity": "sha512-Pp1JylTqlgMJvxW6MGyfTF8vGvlBSCAvMFaDCYa82Mgw7TT5eE5kkHgDvmOGHWeJE4zIDfCpCxHapsK2LtIAJg==",
-			"license": "MIT",
-			"dependencies": {
-				"rfdc": "^1.4.1"
-			}
+			"version": "8.1.2",
+			"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-8.1.2.tgz",
+			"integrity": "sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==",
+			"license": "MIT"
 		},
 		"node_modules/@vue/reactivity": {
 			"version": "3.5.34",
@@ -2086,21 +2133,6 @@
 			"integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==",
 			"license": "MIT"
 		},
-		"node_modules/copy-anything": {
-			"version": "4.0.5",
-			"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
-			"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
-			"license": "MIT",
-			"dependencies": {
-				"is-what": "^5.2.0"
-			},
-			"engines": {
-				"node": ">=18"
-			},
-			"funding": {
-				"url": "https://github.com/sponsors/mesqueeb"
-			}
-		},
 		"node_modules/cosmiconfig": {
 			"version": "9.0.1",
 			"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz",
@@ -4036,15 +4068,6 @@
 				"node": ">=8"
 			}
 		},
-		"node_modules/is-plain-object": {
-			"version": "5.0.0",
-			"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
-			"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
-			"dev": true,
-			"engines": {
-				"node": ">=0.10.0"
-			}
-		},
 		"node_modules/is-regex": {
 			"version": "1.2.1",
 			"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -4181,18 +4204,6 @@
 				"url": "https://github.com/sponsors/ljharb"
 			}
 		},
-		"node_modules/is-what": {
-			"version": "5.5.0",
-			"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
-			"integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
-			"license": "MIT",
-			"engines": {
-				"node": ">=18"
-			},
-			"funding": {
-				"url": "https://github.com/sponsors/mesqueeb"
-			}
-		},
 		"node_modules/isarray": {
 			"version": "2.0.5",
 			"resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz",
@@ -4798,12 +4809,6 @@
 				"url": "https://github.com/sponsors/ljharb"
 			}
 		},
-		"node_modules/mitt": {
-			"version": "3.0.1",
-			"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
-			"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
-			"license": "MIT"
-		},
 		"node_modules/mlly": {
 			"version": "1.8.2",
 			"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
@@ -5577,12 +5582,6 @@
 				"node": ">=0.10.0"
 			}
 		},
-		"node_modules/rfdc": {
-			"version": "1.4.1",
-			"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
-			"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
-			"license": "MIT"
-		},
 		"node_modules/rimraf": {
 			"version": "3.0.2",
 			"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
@@ -5600,13 +5599,13 @@
 			}
 		},
 		"node_modules/rolldown": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
-			"integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
+			"version": "1.0.1",
+			"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
+			"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
 			"license": "MIT",
 			"dependencies": {
-				"@oxc-project/types": "=0.129.0",
-				"@rolldown/pluginutils": "1.0.0"
+				"@oxc-project/types": "=0.130.0",
+				"@rolldown/pluginutils": "^1.0.0"
 			},
 			"bin": {
 				"rolldown": "bin/cli.mjs"
@@ -5615,28 +5614,22 @@
 				"node": "^20.19.0 || >=22.12.0"
 			},
 			"optionalDependencies": {
-				"@rolldown/binding-android-arm64": "1.0.0",
-				"@rolldown/binding-darwin-arm64": "1.0.0",
-				"@rolldown/binding-darwin-x64": "1.0.0",
-				"@rolldown/binding-freebsd-x64": "1.0.0",
-				"@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
-				"@rolldown/binding-linux-arm64-gnu": "1.0.0",
-				"@rolldown/binding-linux-arm64-musl": "1.0.0",
-				"@rolldown/binding-linux-ppc64-gnu": "1.0.0",
-				"@rolldown/binding-linux-s390x-gnu": "1.0.0",
-				"@rolldown/binding-linux-x64-gnu": "1.0.0",
-				"@rolldown/binding-linux-x64-musl": "1.0.0",
-				"@rolldown/binding-openharmony-arm64": "1.0.0",
-				"@rolldown/binding-wasm32-wasi": "1.0.0",
-				"@rolldown/binding-win32-arm64-msvc": "1.0.0",
-				"@rolldown/binding-win32-x64-msvc": "1.0.0"
-			}
-		},
-		"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
-			"version": "1.0.0",
-			"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
-			"integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
-			"license": "MIT"
+				"@rolldown/binding-android-arm64": "1.0.1",
+				"@rolldown/binding-darwin-arm64": "1.0.1",
+				"@rolldown/binding-darwin-x64": "1.0.1",
+				"@rolldown/binding-freebsd-x64": "1.0.1",
+				"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
+				"@rolldown/binding-linux-arm64-gnu": "1.0.1",
+				"@rolldown/binding-linux-arm64-musl": "1.0.1",
+				"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
+				"@rolldown/binding-linux-s390x-gnu": "1.0.1",
+				"@rolldown/binding-linux-x64-gnu": "1.0.1",
+				"@rolldown/binding-linux-x64-musl": "1.0.1",
+				"@rolldown/binding-openharmony-arm64": "1.0.1",
+				"@rolldown/binding-wasm32-wasi": "1.0.1",
+				"@rolldown/binding-win32-arm64-msvc": "1.0.1",
+				"@rolldown/binding-win32-x64-msvc": "1.0.1"
+			}
 		},
 		"node_modules/run-parallel": {
 			"version": "1.2.0",
@@ -5918,15 +5911,6 @@
 				"node": ">=0.10.0"
 			}
 		},
-		"node_modules/speakingurl": {
-			"version": "14.0.1",
-			"resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
-			"integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
-			"license": "BSD-3-Clause",
-			"engines": {
-				"node": ">=0.10.0"
-			}
-		},
 		"node_modules/standard": {
 			"version": "17.1.2",
 			"resolved": "https://registry.npmjs.org/standard/-/standard-17.1.2.tgz",
@@ -6146,9 +6130,9 @@
 			}
 		},
 		"node_modules/stylelint": {
-			"version": "17.11.0",
-			"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.11.0.tgz",
-			"integrity": "sha512-/3czzmbF9XdGWvReDF3Ex4R23Ajolo7j8RB2bFNEqk6Ht356nlpVV+G5bG2Qt8AW1ofJzXztBRDnAtd7cgowWA==",
+			"version": "17.11.1",
+			"resolved": "https://registry.npmjs.org/stylelint/-/stylelint-17.11.1.tgz",
+			"integrity": "sha512-+smN/HqVTggUx3iuAzOi9fPh8SrH+cJWlZrYVldXoJ06orWBhZ4Ue/QEp64oei6pVrAh4w3tG+Y12Vw7MbCFRQ==",
 			"dev": true,
 			"funding": [
 				{
@@ -6183,13 +6167,12 @@
 				"html-tags": "^5.1.0",
 				"ignore": "^7.0.5",
 				"import-meta-resolve": "^4.2.0",
-				"is-plain-object": "^5.0.0",
 				"mathml-tag-names": "^4.0.0",
 				"meow": "^14.1.0",
 				"micromatch": "^4.0.8",
 				"normalize-path": "^3.0.0",
 				"picocolors": "^1.1.1",
-				"postcss": "^8.5.13",
+				"postcss": "^8.5.14",
 				"postcss-safe-parser": "^7.0.1",
 				"postcss-selector-parser": "^7.1.1",
 				"postcss-value-parser": "^4.2.0",
@@ -6333,18 +6316,6 @@
 				"url": "https://github.com/chalk/strip-ansi?sponsor=1"
 			}
 		},
-		"node_modules/superjson": {
-			"version": "2.2.6",
-			"resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
-			"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
-			"license": "MIT",
-			"dependencies": {
-				"copy-anything": "^4"
-			},
-			"engines": {
-				"node": ">=16"
-			}
-		},
 		"node_modules/supports-color": {
 			"version": "7.2.0",
 			"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -6798,15 +6769,15 @@
 			}
 		},
 		"node_modules/vite": {
-			"version": "8.0.12",
-			"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
-			"integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
+			"version": "8.0.13",
+			"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
+			"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
 			"license": "MIT",
 			"dependencies": {
 				"lightningcss": "^1.32.0",
 				"picomatch": "^4.0.4",
 				"postcss": "^8.5.14",
-				"rolldown": "1.0.0",
+				"rolldown": "1.0.1",
 				"tinyglobby": "^0.2.16"
 			},
 			"bin": {
@@ -6908,18 +6879,18 @@
 			}
 		},
 		"node_modules/vue-i18n": {
-			"version": "11.4.2",
-			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.2.tgz",
-			"integrity": "sha512-sADDeKXqAGsPX6tK3t3y2ZiMpbVWN12tG+MhTiJ06rVoh58eGtM4wFyw3uWGbVkXByVp9Ne/AP+nSSzI+J9OAQ==",
+			"version": "11.4.4",
+			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.4.tgz",
+			"integrity": "sha512-gIbXVSFQV4jcSJxfwdZ5zSZmZ+12CnX0K3vBkRSd6Zn+HSzCp+QwUgPwpD/uN0oKNKI9RzlUXPKVedEuMgNG0A==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/core-base": "11.4.2",
-				"@intlify/devtools-types": "11.4.2",
-				"@intlify/shared": "11.4.2",
+				"@intlify/core-base": "11.4.4",
+				"@intlify/devtools-types": "11.4.4",
+				"@intlify/shared": "11.4.4",
 				"@vue/devtools-api": "^6.5.0"
 			},
 			"engines": {
-				"node": ">= 16"
+				"node": ">= 22"
 			},
 			"funding": {
 				"url": "https://github.com/sponsors/kazupon"
@@ -6929,14 +6900,14 @@
 			}
 		},
 		"node_modules/vue-router": {
-			"version": "5.0.6",
-			"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.6.tgz",
-			"integrity": "sha512-9+kmUTGbKMyW9Asoy98IXXYIzrTMT7JDAdpDDeEkorHvybpUvBI2wsrSM5jFOXrFydpzRFJ9vAh+80DN2PGu9w==",
+			"version": "5.0.7",
+			"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-5.0.7.tgz",
+			"integrity": "sha512-dqfk8kvRbCutmCOCj/XLDqDEYxc1wBdAOGLuVy5M93ifYMsBd5fIjfaPN4tQAbxr5IprdBDIox1gr4wYyOx/SA==",
 			"license": "MIT",
 			"dependencies": {
-				"@babel/generator": "^7.28.6",
+				"@babel/generator": "^8.0.0-rc.4",
 				"@vue-macros/common": "^3.1.1",
-				"@vue/devtools-api": "^8.0.6",
+				"@vue/devtools-api": "^8.1.1",
 				"ast-walker-scope": "^0.8.3",
 				"chokidar": "^5.0.0",
 				"json5": "^2.2.3",
@@ -6957,9 +6928,9 @@
 			},
 			"peerDependencies": {
 				"@pinia/colada": ">=0.21.2",
-				"@vue/compiler-sfc": "^3.5.17",
+				"@vue/compiler-sfc": "^3.5.34",
 				"pinia": "^3.0.4",
-				"vue": "^3.5.0"
+				"vue": "^3.5.34"
 			},
 			"peerDependenciesMeta": {
 				"@pinia/colada": {
@@ -6974,12 +6945,12 @@
 			}
 		},
 		"node_modules/vue-router/node_modules/@vue/devtools-api": {
-			"version": "8.0.6",
-			"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.0.6.tgz",
-			"integrity": "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA==",
+			"version": "8.1.2",
+			"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-8.1.2.tgz",
+			"integrity": "sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg==",
 			"license": "MIT",
 			"dependencies": {
-				"@vue/devtools-kit": "^8.0.6"
+				"@vue/devtools-kit": "^8.1.2"
 			}
 		},
 		"node_modules/vue-router/node_modules/json5": {

+ 6 - 6
frontend/package.json

@@ -6,7 +6,7 @@
 	"source": "index.html",
 	"devDependencies": {
 		"process": "^0.11.10",
-		"stylelint": "^17.11.0",
+		"stylelint": "^17.11.1",
 		"stylelint-config-standard": "^40.0.0"
 	},
 	"scripts": {
@@ -24,9 +24,9 @@
 	"dependencies": {
 		"@connectrpc/connect": "^2.1.1",
 		"@connectrpc/connect-web": "^2.1.1",
-		"@hugeicons/core-free-icons": "^4.1.3",
+		"@hugeicons/core-free-icons": "^4.1.4",
 		"@hugeicons/vue": "^1.0.5",
-		"@vitejs/plugin-vue": "^6.0.6",
+		"@vitejs/plugin-vue": "^6.0.7",
 		"@xterm/addon-fit": "^0.11.0",
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/xterm": "^6.0.0",
@@ -34,9 +34,9 @@
 		"picocrank": "^1.15.0",
 		"standard": "^17.1.2",
 		"unplugin-vue-components": "^32.0.0",
-		"vite": "^8.0.12",
+		"vite": "^8.0.13",
 		"vue": "^3.5.34",
-		"vue-i18n": "^11.4.2",
-		"vue-router": "^5.0.6"
+		"vue-i18n": "^11.4.4",
+		"vue-router": "^5.0.7"
 	}
 }

+ 8 - 1
frontend/resources/vue/ActionButton.vue

@@ -10,6 +10,9 @@
 				<div v-if="navigateOnStart == 'arg'" class="navigate-on-start" title="Opens an argument form on start">
 					<HugeiconsIcon :icon="TypeCursorIcon" />
 				</div>
+				<div v-if="navigateOnStart == 'hist'" class="navigate-on-start" title="Opens action execution history on start">
+					<HugeiconsIcon :icon="WorkHistoryIcon" />
+				</div>
 				<div v-if="navigateOnStart == ''" class="navigate-on-start" title="Run in the background">
 					<HugeiconsIcon :icon="WorkoutRunIcon" />
 				</div>
@@ -28,7 +31,7 @@ import { buttonResults } from './stores/buttonResults'
 import { rateLimits } from './stores/rateLimits'
 import { useRouter } from 'vue-router'
 import { HugeiconsIcon } from '@hugeicons/vue'
-import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon } from '@hugeicons/core-free-icons'
+import { WorkoutRunIcon, TypeCursorIcon, ComputerTerminal01Icon, WorkHistoryIcon } from '@hugeicons/core-free-icons'
 
 import ActionIconGlyph from './components/ActionIconGlyph.vue'
 
@@ -101,6 +104,8 @@ function constructFromJson(json) {
 
   if (popupOnStart.value.includes('execution-dialog')) {
 	navigateOnStart.value = 'pop'
+  } else if (popupOnStart.value === 'history') {
+	navigateOnStart.value = 'hist'
   } else if (props.actionData.arguments.length > 0) {
 	navigateOnStart.value = 'arg'
   }
@@ -233,6 +238,8 @@ function onLogEntryChanged(logEntry) {
 function onExecutionStarted(logEntry) {
   if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
 	router.push(`/logs/${logEntry.executionTrackingId}`)
+  } else if (popupOnStart.value === 'history') {
+	router.push(`/action/${bindingId.value}`)
   }
 
   isDisabled.value = true

+ 84 - 41
frontend/resources/vue/views/ActionDetailsView.vue

@@ -17,7 +17,7 @@
             <dt>Timeout</dt>
             <dd>{{ action.timeout }} seconds</dd>
           </dl>
-          <p v-if="action" class = "fg1">
+          <p class = "fg1">
             Execution history for this action. You can filter by execution tracking ID.
           </p>
         </div>
@@ -47,6 +47,7 @@
           <thead>
             <tr>
               <th>Timestamp</th>
+              <th>Duration</th>
               <th>Execution ID</th>
               <th>Metadata</th>
               <th>Status</th>
@@ -55,6 +56,7 @@
           <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}`">
                   {{ log.executionTrackingId }}
@@ -70,9 +72,7 @@
                 </span>
               </td>
               <td class="exit-code">
-                <span :class="getStatusClass(log) + ' annotation'">
-                  {{ getStatusText(log) }}
-                </span>
+                <ActionStatusDisplay :logEntry="log" />
               </td>
             </tr>
           </tbody>
@@ -90,11 +90,12 @@
 </template>
 
 <script setup>
-import { ref, computed, onMounted, watch } from 'vue'
+import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
 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'
 
 const route = useRoute()
 const router = useRouter()
@@ -106,6 +107,8 @@ const pageSize = ref(10)
 const currentPage = ref(1)
 const loading = ref(false)
 const totalCount = ref(0)
+const durationClock = ref(Date.now())
+let durationTicker = null
 
 const filteredLogs = computed(() => {
   if (!searchText.value) {
@@ -138,6 +141,7 @@ async function fetchActionLogs() {
       pageSize.value = serverPageSize
     }
     totalCount.value = Number(response.totalCount) || 0
+    syncDurationTicker()
   } catch (err) {
     console.error('Failed to fetch action logs:', err)
     window.showBigError('fetch-action-logs', 'getting action logs', err, false)
@@ -169,6 +173,7 @@ function resetState() {
   currentPage.value = 1
   searchText.value = ''
   loading.value = true
+  syncDurationTicker()
 }
 
 function clearSearch() {
@@ -185,20 +190,78 @@ function formatTimestamp(timestamp) {
   }
 }
 
-function getStatusClass(log) {
-  if (log.timedOut) return 'status-timeout'
-  if (log.blocked) return 'status-blocked'
-  if (log.exitCode !== 0) return 'status-error'
-  return 'status-success'
+function plural(n, singular, pluralForm) {
+  return n === 1 ? `1 ${singular}` : `${n} ${pluralForm}`
+}
+
+function formatDurationSimple(ms) {
+  if (!Number.isFinite(ms) || ms < 0) {
+    return '—'
+  }
+  const totalSec = Math.round(ms / 1000)
+  if (totalSec === 0) {
+    return '0 seconds'
+  }
+  const days = Math.floor(totalSec / 86400)
+  const hours = Math.floor((totalSec % 86400) / 3600)
+  const minutes = Math.floor((totalSec % 3600) / 60)
+  const seconds = totalSec % 60
+
+  const parts = []
+  if (days > 0) parts.push(plural(days, 'day', 'days'))
+  if (hours > 0) parts.push(plural(hours, 'hour', 'hours'))
+  if (minutes > 0) parts.push(plural(minutes, 'minute', 'minutes'))
+  if (seconds > 0) parts.push(plural(seconds, 'second', 'seconds'))
+  return parts.join(' ')
+}
+
+function formatExecutionDuration(log) {
+  // Reading durationClock keeps this column reactive while executions are in progress.
+  const clock = durationClock.value
+
+  if (!log?.datetimeStarted) {
+    return '—'
+  }
+  const started = new Date(log.datetimeStarted)
+  if (Number.isNaN(started.getTime())) {
+    return '—'
+  }
+
+  let endMs
+  if (log.executionFinished) {
+    const finished = new Date(log.datetimeFinished)
+    if (Number.isNaN(finished.getTime())) {
+      return '—'
+    }
+    endMs = finished.getTime()
+  } else {
+    endMs = clock
+  }
+
+  return formatDurationSimple(endMs - started.getTime())
 }
 
-function getStatusText(log) {
-  if (log.timedOut) return 'Timed out'
-  if (log.blocked) return 'Blocked'
-  if (log.exitCode !== 0) return `Exit code ${log.exitCode}`
-  return 'Completed'
+function syncDurationTicker() {
+  if (durationTicker != null) {
+    clearInterval(durationTicker)
+    durationTicker = null
+  }
+  const hasRunning = logs.value.some(l => !l.executionFinished)
+  if (!hasRunning) {
+    return
+  }
+  durationTicker = window.setInterval(() => {
+    durationClock.value = Date.now()
+  }, 1000)
 }
 
+onUnmounted(() => {
+  if (durationTicker != null) {
+    clearInterval(durationTicker)
+    durationTicker = null
+  }
+})
+
 function handlePageChange(page) {
   currentPage.value = page
   fetchActionLogs()
@@ -247,16 +310,6 @@ watch(
 </script>
 
 <style scoped>
-.action-header {
-  display: flex;
-  align-items: center;
-  gap: 0.5rem;
-}
-
-.action-header h2 {
-  margin: 0;
-}
-
 .icon {
   font-size: 1.5rem;
 }
@@ -288,6 +341,12 @@ watch(
   color: var(--text-secondary);
 }
 
+.duration {
+  font-size: 0.9rem;
+  color: var(--text-secondary);
+  white-space: nowrap;
+}
+
 .empty-state {
   padding: 2rem;
   text-align: center;
@@ -367,22 +426,6 @@ watch(
   font-size: 0.85rem;
 }
 
-.exit-code .status-success {
-  color: #28a745;
-}
-
-.exit-code .status-error {
-  color: #dc3545;
-}
-
-.exit-code .status-timeout {
-  color: #ffc107;
-}
-
-.exit-code .status-blocked {
-  color: #6c757d;
-}
-
 .padding {
   padding: 1rem;
 }

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

@@ -422,6 +422,8 @@ async function handleSubmit(event) {
     const response = await startAction(argvs)
     if (popupOnStart.value && popupOnStart.value.includes('execution-dialog')) {
       router.push(`/logs/${response.executionTrackingId}`)
+    } else if (popupOnStart.value === 'history') {
+      router.push(`/action/${props.bindingId}`)
     } else {
       router.back()
     }

+ 47 - 32
frontend/resources/vue/views/EntitiesView.vue

@@ -1,51 +1,66 @@
 <template>
-	<Section class = "with-header-and-content" v-if="entityDefinitions.length === 0" title="Loading entity definitions...">
-		<div class = "section-header">
-			<h2 class="loading-message">
-				Loading entity definitions...
-			</h2>
-		</div>
+	<Section v-if="!definitionsLoaded" title="Loading entity definitions..." />
+	<Section
+		v-else-if="totalInstances === 0"
+		title="There are no entities to show yet."
+	>
+		<p>
+			When OliveTin has registered entity instances (for example from entity files or your setup), they will be listed here.
+		</p>
 	</Section>
 	<template v-else>
 		<Section v-for="def in entityDefinitions" :key="def.title" :title="'Entity: ' + def.title ">
-			<div class = "section-content">
-				<p>{{ def.instances.length }} instances.</p>
+			<p>{{ def.instances.length }} instances.</p>
 
-				<ul>
-					<li v-for="inst in def.instances" :key="inst.uniqueKey">
-						<router-link :to="{ name: 'EntityDetails', params: { entityType: inst.type, entityKey: inst.uniqueKey } }">
-							{{ inst.title }}
-						</router-link>
-					</li>
-				</ul>
+			<ul>
+				<li v-for="inst in def.instances" :key="inst.uniqueKey">
+					<router-link :to="{ name: 'EntityDetails', params: { entityType: inst.type, entityKey: inst.uniqueKey } }">
+						{{ inst.title }}
+					</router-link>
+				</li>
+			</ul>
 
-				<h3>Used on Dashboards:</h3>
-				<ul>
-					<li v-for="dash in filteredDashboards(def.usedOnDashboards)" :key="dash">
-						<template v-if="isEntityDirectory(dash)">
-							{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
-						</template>
-						<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
-							{{ getDashboardTitle(dash) }}
-						</router-link>
-						<span v-else>{{ dash }}</span>
-					</li>
-				</ul>
-			</div>
+			<h3>Used on Dashboards:</h3>
+			<ul>
+				<li v-for="dash in filteredDashboards(def.usedOnDashboards)" :key="dash">
+					<template v-if="isEntityDirectory(dash)">
+						{{ getDashboardTitle(dash) }} <span class="entity-directory-label">[Entity Directory]</span>
+					</template>
+					<router-link v-else-if="!dash.includes('entity:')" :to="{ name: 'Dashboard', params: { title: getDashboardTitle(dash) } }">
+						{{ getDashboardTitle(dash) }}
+					</router-link>
+					<span v-else>{{ dash }}</span>
+				</li>
+			</ul>
 		</Section>
 	</template>
 </template>
 
 <script setup>
-	import { ref, onMounted } from 'vue'
+	import { ref, computed, onMounted } from 'vue'
 	import Section from 'picocrank/vue/components/Section.vue'
 
+	const definitionsLoaded = ref(false)
 	const entityDefinitions = ref([])
 
-	async function fetchEntities() {
-	    const ret = await window.client.getEntities()
+	const totalInstances = computed(() =>
+		entityDefinitions.value.reduce(
+			(sum, def) => sum + (def.instances?.length ?? 0),
+			0,
+		),
+	)
 
-        entityDefinitions.value = ret.entityDefinitions
+	async function fetchEntities() {
+		try {
+			const ret = await window.client.getEntities()
+			entityDefinitions.value = ret.entityDefinitions ?? []
+		} catch (err) {
+			console.error('Failed to fetch entities:', err)
+			window.showBigError('fetch-entities', 'getting entities', err, false)
+			entityDefinitions.value = []
+		} finally {
+			definitionsLoaded.value = true
+		}
 	}
 
 	function filteredDashboards(dashboards) {

+ 20 - 20
integration-tests/package-lock.json

@@ -9,13 +9,13 @@
       "version": "1.0.0",
       "license": "AGPL-3.0-only",
       "dependencies": {
-        "wait-on": "^9.0.6"
+        "wait-on": "^9.0.10"
       },
       "devDependencies": {
         "chai": "^6.2.2",
-        "eslint": "^10.3.0",
+        "eslint": "^10.4.0",
         "mocha": "^11.7.5",
-        "selenium-webdriver": "^4.43.0"
+        "selenium-webdriver": "^4.44.0"
       }
     },
     "node_modules/@aashutoshrathi/word-wrap": {
@@ -92,9 +92,9 @@
       }
     },
     "node_modules/@eslint/config-helpers": {
-      "version": "0.5.5",
-      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.5.tgz",
-      "integrity": "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==",
+      "version": "0.6.0",
+      "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
+      "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
       "dev": true,
       "license": "Apache-2.0",
       "dependencies": {
@@ -783,16 +783,16 @@
       }
     },
     "node_modules/eslint": {
-      "version": "10.3.0",
-      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz",
-      "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==",
+      "version": "10.4.0",
+      "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz",
+      "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==",
       "dev": true,
       "license": "MIT",
       "dependencies": {
         "@eslint-community/eslint-utils": "^4.8.0",
         "@eslint-community/regexpp": "^4.12.2",
         "@eslint/config-array": "^0.23.5",
-        "@eslint/config-helpers": "^0.5.5",
+        "@eslint/config-helpers": "^0.6.0",
         "@eslint/core": "^1.2.1",
         "@eslint/plugin-kit": "^0.7.1",
         "@humanfs/node": "^0.16.6",
@@ -1866,9 +1866,9 @@
       "dev": true
     },
     "node_modules/selenium-webdriver": {
-      "version": "4.43.0",
-      "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.43.0.tgz",
-      "integrity": "sha512-dV4zBTT37or3Z3/8uD6rS8zvd4ZxPuG4EJVlqYIbZCGZCYttZm7xb9rlFLSk4rrsQHAeDYvudl7cquo0vWpHjg==",
+      "version": "4.44.0",
+      "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.44.0.tgz",
+      "integrity": "sha512-7RbYoKK0zET+KMVak11UDCtKvNulOU6gFZp8HI5GN9K8+BhqrliIJU/FP6QADrvRAXFMr3wHxfE3JHOcAxO3GQ==",
       "dev": true,
       "funding": [
         {
@@ -1885,7 +1885,7 @@
         "@bazel/runfiles": "^6.5.0",
         "jszip": "^3.10.1",
         "tmp": "^0.2.5",
-        "ws": "^8.20.0"
+        "ws": "^8.20.1"
       },
       "engines": {
         "node": ">= 20.0.0"
@@ -2123,9 +2123,9 @@
       "dev": true
     },
     "node_modules/wait-on": {
-      "version": "9.0.6",
-      "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.6.tgz",
-      "integrity": "sha512-KR+Te+NBg6DmPVil4anyIO72mpt/QDHjRo3nVFkwRgb26oweUp3DDW2szO3EeUY4cqafWy4rQuOOeEk4n+7Oeg==",
+      "version": "9.0.10",
+      "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz",
+      "integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==",
       "license": "MIT",
       "dependencies": {
         "axios": "^1.16.0",
@@ -2259,9 +2259,9 @@
       }
     },
     "node_modules/ws": {
-      "version": "8.20.0",
-      "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
-      "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
+      "version": "8.20.1",
+      "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
+      "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
       "dev": true,
       "license": "MIT",
       "engines": {

+ 3 - 3
integration-tests/package.json

@@ -12,11 +12,11 @@
   "license": "AGPL-3.0-only",
   "devDependencies": {
     "chai": "^6.2.2",
-    "eslint": "^10.3.0",
+    "eslint": "^10.4.0",
     "mocha": "^11.7.5",
-    "selenium-webdriver": "^4.43.0"
+    "selenium-webdriver": "^4.44.0"
   },
   "dependencies": {
-    "wait-on": "^9.0.6"
+    "wait-on": "^9.0.10"
   }
 }

+ 6 - 6
service/go.mod

@@ -46,7 +46,7 @@ require (
 	buf.build/go/bufprivateusage v0.1.0 // indirect
 	buf.build/go/interrupt v1.1.0 // indirect
 	buf.build/go/protovalidate v1.2.0 // indirect
-	buf.build/go/protoyaml v0.6.0 // indirect
+	buf.build/go/protoyaml v0.7.0 // indirect
 	buf.build/go/spdx v0.2.0 // indirect
 	buf.build/go/standard v0.1.1-0.20260325175353-2b287e071df5 // indirect
 	cel.dev/expr v0.25.2 // indirect
@@ -68,7 +68,7 @@ require (
 	github.com/cristalhq/acmd v0.12.0 // indirect
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	github.com/distribution/reference v0.6.0 // indirect
-	github.com/docker/cli v29.4.3+incompatible // indirect
+	github.com/docker/cli v29.5.1+incompatible // indirect
 	github.com/docker/distribution v2.8.3+incompatible // indirect
 	github.com/docker/docker v28.5.2+incompatible // indirect
 	github.com/docker/docker-credential-helpers v0.9.7 // indirect
@@ -88,9 +88,9 @@ require (
 	github.com/go-toolsmith/typep v1.1.0 // indirect
 	github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
 	github.com/gofrs/flock v0.13.0 // indirect
-	github.com/google/cel-go v0.28.0 // indirect
+	github.com/google/cel-go v0.28.1 // indirect
 	github.com/google/go-cmp v0.7.0 // indirect
-	github.com/google/go-containerregistry v0.21.5 // indirect
+	github.com/google/go-containerregistry v0.21.6 // indirect
 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 	github.com/jdx/go-netrc v1.0.0 // indirect
@@ -157,8 +157,8 @@ require (
 	golang.org/x/text v0.37.0 // indirect
 	golang.org/x/time v0.15.0 // indirect
 	golang.org/x/tools v0.45.0 // indirect
-	google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect
+	google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 // indirect
 	google.golang.org/grpc v1.79.3 // indirect
 	mvdan.cc/xurls/v2 v2.6.0 // indirect
 	pluginrpc.com/pluginrpc v0.5.0 // indirect

+ 12 - 0
service/go.sum

@@ -48,6 +48,8 @@ buf.build/go/protovalidate v1.2.0 h1:DQVrUWkmGTBij+kOYv/x2LLxwcLaGKMdzShj1/6/3H0
 buf.build/go/protovalidate v1.2.0/go.mod h1:7rYiQEhqvAipoazpVNBBH2S2f8bjG4huMVy1V2Yofn4=
 buf.build/go/protoyaml v0.6.0 h1:Nzz1lvcXF8YgNZXk+voPPwdU8FjDPTUV4ndNTXN0n2w=
 buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q=
+buf.build/go/protoyaml v0.7.0 h1:z4oVoFicbpPefhT7WAykxUdfp0yEQlhMQ2mCZOY5V38=
+buf.build/go/protoyaml v0.7.0/go.mod h1:+a0cavd0uMvirb87xdu2ZMMmjlIQoiH/N2Ich5MGSQ0=
 buf.build/go/spdx v0.2.0 h1:IItqM0/cMxvFJJumcBuP8NrsIzMs/UYjp/6WSpq8LTw=
 buf.build/go/spdx v0.2.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8=
 buf.build/go/standard v0.1.0 h1:g98T9IyvAl0vS3Pq8iVk6Cvj2ZiFvoUJRtfyGa0120U=
@@ -166,6 +168,8 @@ github.com/docker/cli v29.3.0+incompatible h1:z3iWveU7h19Pqx7alZES8j+IeFQZ1lhTwb
 github.com/docker/cli v29.3.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU=
 github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/cli v29.5.1+incompatible h1:NiufLAJoRcPauFoBNYthfuM4REFwM8H2h9xnLABNHGs=
+github.com/docker/cli v29.5.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
 github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
@@ -240,6 +244,8 @@ github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
 github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
 github.com/google/cel-go v0.28.0 h1:KjSWstCpz/MN5t4a8gnGJNIYUsJRpdi/r97xWDphIQc=
 github.com/google/cel-go v0.28.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
+github.com/google/cel-go v0.28.1 h1:YWIwi77J4xIsYUwAF/iIuS6haffzIHS8yWI8glSbLWM=
+github.com/google/cel-go v0.28.1/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8=
 github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -252,6 +258,8 @@ github.com/google/go-containerregistry v0.21.3 h1:Xr+yt3VvwOOn/5nJzd7UoOhwPGiPkY
 github.com/google/go-containerregistry v0.21.3/go.mod h1:D5ZrJF1e6dMzvInpBPuMCX0FxURz7GLq2rV3Us9aPkc=
 github.com/google/go-containerregistry v0.21.5 h1:KTJG9Pn/jC0VdZR6ctV3/jcN+q6/Iqlx0sTVz3ywZlM=
 github.com/google/go-containerregistry v0.21.5/go.mod h1:ySvMuiWg+dOsRW0Hw8GYwfMwBlNRTmpYBFJPlkco5zU=
+github.com/google/go-containerregistry v0.21.6 h1:T+yqQIlJXKrM98Om4DlW3GoWQAmhZuLMwoDOvVrtiUM=
+github.com/google/go-containerregistry v0.21.6/go.mod h1:U7MMSBIJynke2MVQrQk19NP9k/uQsGz/h0amIFSHMbo=
 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
@@ -673,6 +681,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:
 google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
 google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348 h1:U8orV30l6KpDsi9dxU0CoJZGbjS8EEpw+6ba+XwGPQA=
 google.golang.org/genproto/googleapis/api v0.0.0-20260504160031-60b97b32f348/go.mod h1:Yzdzr5OOZFgSsEV2D/Xi9NL3bszpXFAg0hFJiRohcD8=
+google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94 h1:DddG61lE5LkX6144z22i0gma9BMBs5aZ9B8lZLobxyw=
+google.golang.org/genproto/googleapis/api v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:1dCETSCY2YKZNXQE3h4fun3TYwF5p8jejRKZgfWAgAY=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU=
@@ -687,6 +697,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94 h1:eZCjr/aAF8c5ccm5pb6T4EXgIei5MlAAPWPJk+5ArfY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260519071638-aa98bba5eb94/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
 google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
 google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
 google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=

+ 55 - 6
service/internal/api/api.go

@@ -204,10 +204,24 @@ func (api *oliveTinAPI) applyLocalLoginResult(req *apiv1.LocalUserLoginRequest,
 	}
 }
 
-func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) {
+func (api *oliveTinAPI) localUserLoginEarlyReject(req *connect.Request[apiv1.LocalUserLoginRequest]) *connect.Response[apiv1.LocalUserLoginResponse] {
 	if !api.cfg.AuthLocalUsers.Enabled {
-		return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false}), nil
+		return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false})
+	}
+
+	if isLocalInteractiveLoginDisabledForUser(api.cfg, req.Msg.Username) {
+		log.WithFields(log.Fields{"username": req.Msg.Username}).Debug("LocalUserLogin: interactive login disabled (no password configured)")
+		return connect.NewResponse(&apiv1.LocalUserLoginResponse{Success: false})
+	}
+
+	return nil
+}
+
+func (api *oliveTinAPI) LocalUserLogin(ctx ctx.Context, req *connect.Request[apiv1.LocalUserLoginRequest]) (*connect.Response[apiv1.LocalUserLoginResponse], error) {
+	if early := api.localUserLoginEarlyReject(req); early != nil {
+		return early, nil
 	}
+
 	match, err := checkUserPassword(api.cfg, req.Msg.Username, req.Msg.Password)
 	if err != nil {
 		if errors.Is(err, ErrArgon2Busy) {
@@ -640,12 +654,13 @@ func calculateReversedIndices(page pageInfo, filteredLen int) (int64, int64) {
 	return startIdx, endIdx
 }
 
-// buildActionLogsResponse builds the response with paginated log entries.
+// buildActionLogsResponse builds the response with paginated log entries (newest first).
 func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLogEntry, page pageInfo, user *authpublic.AuthenticatedUser) *apiv1.GetActionLogsResponse {
 	startIdx, endIdx := calculateReversedIndices(page, len(filtered))
 	ret := &apiv1.GetActionLogsResponse{}
-	for _, le := range filtered[startIdx:endIdx] {
-		ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
+	chunk := filtered[int(startIdx):int(endIdx)]
+	for i := len(chunk) - 1; i >= 0; i-- {
+		ret.Logs = append(ret.Logs, api.internalLogEntryToPb(chunk[i], user))
 	}
 	ret.CountRemaining = page.start
 	ret.PageSize = page.size
@@ -723,12 +738,46 @@ func (api *oliveTinAPI) argumentNotFoundForValidation(msg *apiv1.ValidateArgumen
 	return arg == nil
 }
 
+func (api *oliveTinAPI) validateArgumentTypeBindingAccess(user *authpublic.AuthenticatedUser, msg *apiv1.ValidateArgumentTypeRequest) error {
+	if msg == nil || msg.BindingId == "" {
+		return nil
+	}
+
+	return api.errUnlessUserMayValidateArgumentTypeForBinding(user, msg.BindingId)
+}
+
+func (api *oliveTinAPI) errUnlessUserMayValidateArgumentTypeForBinding(user *authpublic.AuthenticatedUser, bindingID string) error {
+	binding := api.executor.FindBindingByID(bindingID)
+	if binding == nil || binding.Action == nil {
+		return connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", bindingID))
+	}
+
+	if !api.userCanViewAction(user, binding.Action) {
+		return connect.NewError(connect.CodePermissionDenied, fmt.Errorf("permission denied"))
+	}
+
+	return nil
+}
+
 func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Request[apiv1.ValidateArgumentTypeRequest]) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
+	user := auth.UserFromApiCall(ctx, req, api.cfg)
+	if err := api.checkDashboardAccess(user); err != nil {
+		return nil, err
+	}
+
+	if err := api.validateArgumentTypeBindingAccess(user, req.Msg); err != nil {
+		return nil, err
+	}
+
 	if api.argumentNotFoundForValidation(req.Msg) {
 		return nil, connect.NewError(connect.CodeNotFound, fmt.Errorf("action or argument not found for binding ID %s", req.Msg.BindingId))
 	}
 
-	err := api.validateArgumentTypeInternal(req.Msg)
+	return api.validateArgumentTypeConnectResponse(req.Msg)
+}
+
+func (api *oliveTinAPI) validateArgumentTypeConnectResponse(msg *apiv1.ValidateArgumentTypeRequest) (*connect.Response[apiv1.ValidateArgumentTypeResponse], error) {
+	err := api.validateArgumentTypeInternal(msg)
 	desc := ""
 	if err != nil {
 		desc = err.Error()

+ 90 - 0
service/internal/api/api_test.go

@@ -408,6 +408,96 @@ func TestGetActionBindingDeniedWhenNoViewPermission(t *testing.T) {
 		"user with view:false must get permission denied from GetActionBinding")
 }
 
+// TestValidateArgumentTypeDeniesGuestsWhenLoginRequired (GHSA-f637-w7p2-m7fx) asserts that when
+// guests must log in, ValidateArgumentType does not bypass dashboard access controls.
+func TestValidateArgumentTypeDeniesGuestsWhenLoginRequired(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.AuthRequireGuestsToLogin = true
+	cfg.Actions = append(cfg.Actions, &config.Action{
+		ID:    "a1",
+		Title: "Probe",
+		Shell: "echo",
+		Arguments: []config.ActionArgument{
+			{Name: "x", Type: "ascii"},
+		},
+	})
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClient(cfg)
+	defer ts.Close()
+
+	_, err := client.ValidateArgumentType(context.Background(), connect.NewRequest(&apiv1.ValidateArgumentTypeRequest{
+		BindingId:    "a1",
+		ArgumentName: "x",
+		Value:        "v",
+		Type:         "ascii",
+	}))
+	require.Error(t, err)
+	assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err),
+		"guest must not call ValidateArgumentType when AuthRequireGuestsToLogin is true")
+}
+
+// TestValidateArgumentTypeDeniedWithoutViewPermission (GHSA-f637-w7p2-m7fx) asserts ValidateArgumentType
+// respects the same view ACL as GetActionBinding so the RPC cannot enumerate restricted actions.
+func TestValidateArgumentTypeDeniedWithoutViewPermission(t *testing.T) {
+	cfg, _, _ := buildViewPermissionTestConfig(t)
+	cfg.AuthHttpHeaderUsername = "X-Ot-User"
+	for i := range cfg.Actions {
+		if cfg.Actions[i].ID == "secret_action" {
+			cfg.Actions[i].Arguments = []config.ActionArgument{{Name: "target", Type: "ascii"}}
+			break
+		}
+	}
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClient(cfg)
+	defer ts.Close()
+
+	req := connect.NewRequest(&apiv1.ValidateArgumentTypeRequest{
+		BindingId:    "secret_action",
+		ArgumentName: "target",
+		Value:        "ok",
+		Type:         "ascii",
+	})
+	req.Header().Set("X-Ot-User", "low")
+
+	_, err := client.ValidateArgumentType(context.Background(), req)
+	require.Error(t, err)
+	assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(err),
+		"user with view:false must get permission denied from ValidateArgumentType")
+}
+
+// TestValidateArgumentTypeAllowedWithViewPermission (GHSA-f637-w7p2-m7fx) asserts authenticated users
+// with view access can still use ValidateArgumentType for argument validation.
+func TestValidateArgumentTypeAllowedWithViewPermission(t *testing.T) {
+	cfg, _, _ := buildViewPermissionTestConfig(t)
+	cfg.AuthHttpHeaderUsername = "X-Ot-User"
+	for i := range cfg.Actions {
+		if cfg.Actions[i].ID == "secret_action" {
+			cfg.Actions[i].Arguments = []config.ActionArgument{{Name: "target", Type: "ascii"}}
+			break
+		}
+	}
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+	ts, client := getNewTestServerAndClient(cfg)
+	defer ts.Close()
+
+	req := connect.NewRequest(&apiv1.ValidateArgumentTypeRequest{
+		BindingId:    "secret_action",
+		ArgumentName: "target",
+		Value:        "ok",
+		Type:         "ascii",
+	})
+	req.Header().Set("X-Ot-User", "admin")
+
+	resp, err := client.ValidateArgumentType(context.Background(), req)
+	require.NoError(t, err)
+	require.NotNil(t, resp)
+	require.NotNil(t, resp.Msg)
+	assert.True(t, resp.Msg.Valid, "admin with view:true should get successful validation for a valid ascii value")
+}
+
 // TestViewPermissionAllowedSeesAction (GHSA: view permission) asserts that a user with view: true
 // still sees the action in the dashboard and can fetch it via GetActionBinding.
 func TestViewPermissionAllowedSeesAction(t *testing.T) {

+ 9 - 0
service/internal/api/local_user_login.go

@@ -61,6 +61,15 @@ func comparePasswordAndHash(password, hash string) (bool, error) {
 	return match, nil
 }
 
+func isLocalInteractiveLoginDisabledForUser(cfg *config.Config, username string) bool {
+	user := cfg.FindUserByUsername(username)
+	if user == nil {
+		return false
+	}
+
+	return user.Password == ""
+}
+
 func checkUserPassword(cfg *config.Config, username, password string) (bool, error) {
 	user := cfg.FindUserByUsername(username)
 	if user == nil {

+ 35 - 0
service/internal/api/local_user_login_test.go

@@ -0,0 +1,35 @@
+package api
+
+import (
+	"context"
+	"testing"
+
+	"connectrpc.com/connect"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
+	config "github.com/OliveTin/OliveTin/internal/config"
+)
+
+func TestLocalUserLoginRejectsUserWithNoPassword(t *testing.T) {
+	t.Parallel()
+
+	cfg := config.DefaultConfig()
+	cfg.AuthLocalUsers.Enabled = true
+	cfg.AuthLocalUsers.Users = []*config.LocalUser{{
+		Username: "onlykey",
+		ApiKey:   "k",
+		Password: "",
+	}}
+
+	ts, client := getNewTestServerAndClient(cfg)
+	defer ts.Close()
+
+	resp, err := client.LocalUserLogin(context.Background(), connect.NewRequest(&apiv1.LocalUserLoginRequest{
+		Username: "onlykey",
+		Password: "anything",
+	}))
+	require.NoError(t, err)
+	assert.False(t, resp.Msg.GetSuccess())
+}

+ 1 - 0
service/internal/auth/authcheck.go

@@ -14,6 +14,7 @@ import (
 var authChain = []func(*types.AuthCheckingContext) *types.AuthenticatedUser{
 	checkUserFromHeaders,
 	checkUserFromLocalSession,
+	checkUserFromLocalBearerApiKey,
 	otjwt.CheckUserFromJwtHeader,
 	otjwt.CheckUserFromJwtCookie,
 }

+ 109 - 0
service/internal/auth/local_bearer.go

@@ -0,0 +1,109 @@
+package auth
+
+import (
+	"crypto/subtle"
+	"strings"
+
+	types "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	"github.com/OliveTin/OliveTin/internal/config"
+	log "github.com/sirupsen/logrus"
+)
+
+const localBearerScheme = "Bearer"
+
+func constantTimeEqualString(a, b string) bool {
+	if len(a) != len(b) {
+		return false
+	}
+
+	return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
+}
+
+func bearerTokenFromAuthorizationHeader(authz string) (string, bool) {
+	idx := strings.IndexByte(authz, ' ')
+	if idx <= 0 {
+		return "", false
+	}
+
+	if !strings.EqualFold(authz[:idx], localBearerScheme) {
+		return "", false
+	}
+
+	token := strings.TrimSpace(authz[idx+1:])
+	if token == "" {
+		return "", false
+	}
+
+	return token, true
+}
+
+func localUserHasAPIKey(user *config.LocalUser) bool {
+	return user != nil && user.ApiKey != ""
+}
+
+func findLocalUserByAPIKey(cfg *config.Config, token string) *config.LocalUser {
+	for _, user := range cfg.AuthLocalUsers.Users {
+		if !localUserHasAPIKey(user) {
+			continue
+		}
+
+		if constantTimeEqualString(token, user.ApiKey) {
+			return user
+		}
+	}
+
+	return nil
+}
+
+func localBearerAuthorizationHasEmptyCredential(authz string) bool {
+	idx := strings.IndexByte(authz, ' ')
+	return idx > 0 &&
+		strings.EqualFold(authz[:idx], localBearerScheme) &&
+		strings.TrimSpace(authz[idx+1:]) == ""
+}
+
+func logLocalBearerAPIKeyParseFailure(authz string) {
+	if strings.TrimSpace(authz) == "" {
+		return
+	}
+
+	if localBearerAuthorizationHasEmptyCredential(authz) {
+		log.Debugf("Local bearer API key: rejected (empty credential after Bearer prefix)")
+		return
+	}
+
+	log.Tracef("Local bearer API key: skipped (Authorization is not a Bearer token)")
+}
+
+func checkUserFromLocalBearerApiKey(context *types.AuthCheckingContext) *types.AuthenticatedUser {
+	if !context.Config.AuthLocalUsers.Enabled {
+		log.Tracef("Local bearer API key: skipped (authLocalUsers disabled)")
+		return nil
+	}
+
+	authz := context.Request.Header.Get("Authorization")
+	token, ok := bearerTokenFromAuthorizationHeader(authz)
+	if !ok {
+		logLocalBearerAPIKeyParseFailure(authz)
+		return nil
+	}
+
+	log.Debugf("Local bearer API key: checking configured local user API keys")
+
+	user := findLocalUserByAPIKey(context.Config, token)
+	if user == nil {
+		log.Debugf("Local bearer API key: rejected (no matching local user)")
+		return nil
+	}
+
+	log.WithFields(log.Fields{
+		"username":  user.Username,
+		"usergroup": user.Usergroup,
+	}).Debugf("Local bearer API key: authenticated")
+
+	return &types.AuthenticatedUser{
+		Username:      user.Username,
+		UsergroupLine: user.Usergroup,
+		Provider:      "local",
+	}
+}

+ 106 - 0
service/internal/auth/local_bearer_test.go

@@ -0,0 +1,106 @@
+package auth
+
+import (
+	"net/http/httptest"
+	"testing"
+
+	authpublic "github.com/OliveTin/OliveTin/internal/auth/authpublic"
+	config "github.com/OliveTin/OliveTin/internal/config"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestCheckUserFromLocalBearerApiKey_Match_LowercaseBearerScheme(t *testing.T) {
+	t.Parallel()
+
+	cfg := config.DefaultConfig()
+	cfg.AuthLocalUsers.Enabled = true
+	cfg.AuthLocalUsers.Users = []*config.LocalUser{{
+		Username:  "bot",
+		Usergroup: "bots",
+		ApiKey:    "secret-api-key",
+	}}
+
+	req := httptest.NewRequest("POST", "/", nil)
+	req.Header.Set("Authorization", "bearer secret-api-key")
+
+	ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
+	user := checkUserFromLocalBearerApiKey(ctx)
+	require.NotNil(t, user)
+	assert.Equal(t, "bot", user.Username)
+	assert.Equal(t, "bots", user.UsergroupLine)
+	assert.Equal(t, "local", user.Provider)
+}
+
+func TestCheckUserFromLocalBearerApiKey_Match(t *testing.T) {
+	t.Parallel()
+
+	cfg := config.DefaultConfig()
+	cfg.AuthLocalUsers.Enabled = true
+	cfg.AuthLocalUsers.Users = []*config.LocalUser{{
+		Username:  "bot",
+		Usergroup: "bots",
+		ApiKey:    "secret-api-key",
+	}}
+
+	req := httptest.NewRequest("POST", "/", nil)
+	req.Header.Set("Authorization", "Bearer secret-api-key")
+
+	ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
+	user := checkUserFromLocalBearerApiKey(ctx)
+	require.NotNil(t, user)
+	assert.Equal(t, "bot", user.Username)
+	assert.Equal(t, "bots", user.UsergroupLine)
+	assert.Equal(t, "local", user.Provider)
+}
+
+func TestCheckUserFromLocalBearerApiKey_WrongKey(t *testing.T) {
+	t.Parallel()
+
+	cfg := config.DefaultConfig()
+	cfg.AuthLocalUsers.Enabled = true
+	cfg.AuthLocalUsers.Users = []*config.LocalUser{{
+		Username: "bot",
+		ApiKey:   "secret-api-key",
+	}}
+
+	req := httptest.NewRequest("POST", "/", nil)
+	req.Header.Set("Authorization", "Bearer wrong")
+
+	ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
+	assert.Nil(t, checkUserFromLocalBearerApiKey(ctx))
+}
+
+func TestCheckUserFromLocalBearerApiKey_DisabledLocalUsers(t *testing.T) {
+	t.Parallel()
+
+	cfg := config.DefaultConfig()
+	cfg.AuthLocalUsers.Enabled = false
+	cfg.AuthLocalUsers.Users = []*config.LocalUser{{
+		Username: "bot",
+		ApiKey:   "secret-api-key",
+	}}
+
+	req := httptest.NewRequest("POST", "/", nil)
+	req.Header.Set("Authorization", "Bearer secret-api-key")
+
+	ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
+	assert.Nil(t, checkUserFromLocalBearerApiKey(ctx))
+}
+
+func TestCheckUserFromLocalBearerApiKey_NoBearerPrefix(t *testing.T) {
+	t.Parallel()
+
+	cfg := config.DefaultConfig()
+	cfg.AuthLocalUsers.Enabled = true
+	cfg.AuthLocalUsers.Users = []*config.LocalUser{{
+		Username: "bot",
+		ApiKey:   "secret-api-key",
+	}}
+
+	req := httptest.NewRequest("POST", "/", nil)
+	req.Header.Set("Authorization", "secret-api-key")
+
+	ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
+	assert.Nil(t, checkUserFromLocalBearerApiKey(ctx))
+}

+ 1 - 0
service/internal/config/config.go

@@ -195,6 +195,7 @@ type LocalUser struct {
 	Username  string `koanf:"username"`
 	Usergroup string `koanf:"usergroup"`
 	Password  string `koanf:"password"`
+	ApiKey    string `koanf:"apiKey"`
 }
 
 type OAuth2Provider struct {

+ 55 - 9
service/internal/config/sanitize.go

@@ -1,6 +1,7 @@
 package config
 
 import (
+	"fmt"
 	"strings"
 	"text/template"
 
@@ -15,7 +16,7 @@ func (cfg *Config) Sanitize() {
 	cfg.sanitizeLogLevel()
 	cfg.sanitizeAuthRequireGuestsToLogin()
 	cfg.sanitizeLogHistoryPageSize()
-	cfg.sanitizeLocalUserPasswords()
+	cfg.sanitizeLocalUsers()
 	cfg.sanitizeSecurityHeaders()
 
 	// log.Infof("cfg %p", cfg)
@@ -177,12 +178,55 @@ func (cfg *Config) sanitizeLogHistoryPageSize() {
 	}
 }
 
-func (cfg *Config) sanitizeLocalUserPasswords() {
+func (cfg *Config) sanitizeLocalUsers() {
 	for _, user := range cfg.AuthLocalUsers.Users {
-		if user.Password != "" {
-			user.Password = parsePasswordTemplate(user.Password)
+		expandLocalUserEnvTemplates(user)
+	}
+
+	if err := validateUniqueLocalUserAPIKeys(cfg.AuthLocalUsers.Users); err != nil {
+		log.Fatalf("%v", err)
+	}
+}
+
+func expandLocalUserEnvTemplates(user *LocalUser) {
+	if user == nil {
+		return
+	}
+
+	if user.Password != "" {
+		user.Password = expandEnvTemplate(user.Password)
+	}
+
+	if user.ApiKey != "" {
+		user.ApiKey = expandEnvTemplate(user.ApiKey)
+	}
+}
+
+// validateUniqueLocalUserAPIKeys returns an error when two local users share the same non-empty apiKey.
+func validateUniqueLocalUserAPIKeys(users []*LocalUser) error {
+	seen := make(map[string]string)
+
+	for _, user := range users {
+		if err := recordUniqueLocalUserAPIKey(seen, user); err != nil {
+			return err
 		}
 	}
+
+	return nil
+}
+
+func recordUniqueLocalUserAPIKey(seen map[string]string, user *LocalUser) error {
+	if user == nil || user.ApiKey == "" {
+		return nil
+	}
+
+	if prior, ok := seen[user.ApiKey]; ok {
+		return fmt.Errorf("duplicate authLocalUsers apiKey for users %q and %q", prior, user.Username)
+	}
+
+	seen[user.ApiKey] = user.Username
+
+	return nil
 }
 
 func (cfg *Config) sanitizeSecurityHeaders() {
@@ -204,16 +248,16 @@ func (cfg *Config) sanitizeSecurityHeadersXFrameOptions() {
 	cfg.Security.XFrameOptions = "DENY"
 }
 
-// parsePasswordTemplate expands {{ .Env.VAR }} in local user password fields using the process environment.
-func parsePasswordTemplate(source string) string {
-	t, err := template.New("password").Option("missingkey=error").Parse(source)
+// expandEnvTemplate expands {{ .Env.VAR }} in config strings using the process environment.
+func expandEnvTemplate(source string) string {
+	t, err := template.New("envTemplate").Option("missingkey=error").Parse(source)
 	if err != nil {
-		log.WithFields(log.Fields{"error": err}).Debug("Password template parse failed, using literal")
+		log.WithFields(log.Fields{"error": err}).Debug("Env template parse failed, using literal")
 		return source
 	}
 	var b strings.Builder
 	if err := t.Execute(&b, map[string]interface{}{"Env": env.BuildEnvMap()}); err != nil {
-		log.WithFields(log.Fields{"error": err}).Debug("Password template execute failed, using literal")
+		log.WithFields(log.Fields{"error": err}).Debug("Env template execute failed, using literal")
 		return source
 	}
 	return b.String()
@@ -242,6 +286,8 @@ func sanitizePopupOnStart(raw string, cfg *Config) string {
 		return raw
 	case "execution-button":
 		return raw
+	case "history":
+		return raw
 	default:
 		return cfg.DefaultPopupOnStart
 	}

+ 36 - 1
service/internal/config/sanitize_test.go

@@ -1,8 +1,10 @@
 package config
 
 import (
-	"github.com/stretchr/testify/assert"
 	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestSanitizeConfig(t *testing.T) {
@@ -37,6 +39,23 @@ func TestSanitizeConfig(t *testing.T) {
 	assert.Equal(t, "Waffle", a2.Arguments[0].Choices[0].Title, "Choice title is set to name")
 }
 
+func TestSanitizePopupOnStartHistory(t *testing.T) {
+	c := DefaultConfig()
+	c.DefaultPopupOnStart = "nothing"
+
+	c.Actions = append(c.Actions, &Action{
+		Title:        "With history",
+		PopupOnStart: "history",
+		Shell:        "true",
+	})
+	c.Sanitize()
+
+	a := c.findAction("With history")
+	if assert.NotNil(t, a) {
+		assert.Equal(t, "history", a.PopupOnStart, "history must be preserved, not replaced by defaultPopupOnStart")
+	}
+}
+
 func TestSanitizeConfigInlineDashboardActions(t *testing.T) {
 	c := DefaultConfig()
 
@@ -72,3 +91,19 @@ func TestSanitizeConfigInlineDashboardActions(t *testing.T) {
 		assert.NotEmpty(t, found.ID, "Inline action should have a generated ID")
 	}
 }
+
+func TestValidateUniqueLocalUserAPIKeys(t *testing.T) {
+	t.Parallel()
+
+	err := validateUniqueLocalUserAPIKeys([]*LocalUser{
+		{Username: "a", ApiKey: "same"},
+		{Username: "b", ApiKey: "same"},
+	})
+	require.Error(t, err)
+
+	err = validateUniqueLocalUserAPIKeys([]*LocalUser{
+		{Username: "a", ApiKey: "one"},
+		{Username: "b", ApiKey: "two"},
+	})
+	require.NoError(t, err)
+}

+ 24 - 1
service/internal/httpservers/frontend.go

@@ -13,6 +13,7 @@ import (
 	"net/http/httputil"
 	"net/url"
 	"path"
+	"strings"
 
 	"github.com/OliveTin/OliveTin/internal/api"
 	"github.com/OliveTin/OliveTin/internal/auth"
@@ -57,13 +58,35 @@ func securityHeadersMiddleware(cfg *config.Config, next http.Handler) http.Handl
 	})
 }
 
+func isSensitiveLogHeaderName(name string) bool {
+	switch strings.ToLower(name) {
+	case "authorization", "cookie", "x-forwarded-access-token":
+		return true
+	default:
+		return false
+	}
+}
+
+func redactHeaderValuesForLog(name string, values []string) []string {
+	if !isSensitiveLogHeaderName(name) {
+		return values
+	}
+
+	out := make([]string, len(values))
+	for i := range values {
+		out[i] = "[redacted]"
+	}
+
+	return out
+}
+
 func logDebugRequest(cfg *config.Config, source string, r *http.Request) {
 	if cfg.LogDebugOptions.SingleFrontendRequests {
 		log.Debugf("SingleFrontend HTTP Req URL %v: %q", source, r.URL)
 
 		if cfg.LogDebugOptions.SingleFrontendRequestHeaders {
 			for name, values := range r.Header {
-				log.Debugf("SingleFrontend HTTP Req Hdr: %v = %v", name, values)
+				log.Debugf("SingleFrontend HTTP Req Hdr: %v = %v", name, redactHeaderValuesForLog(name, values))
 			}
 		}
 	}

+ 17 - 0
service/internal/httpservers/frontend_test.go

@@ -0,0 +1,17 @@
+package httpservers
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestRedactHeaderValuesForLog(t *testing.T) {
+	t.Parallel()
+
+	assert.Equal(t, []string{"[redacted]"}, redactHeaderValuesForLog("Authorization", []string{"Bearer secret"}))
+	assert.Equal(t, []string{"[redacted]", "[redacted]"}, redactHeaderValuesForLog("Cookie", []string{"a=1", "b=2"}))
+	assert.Equal(t, []string{"[redacted]"}, redactHeaderValuesForLog("authorization", []string{"x"}))
+	assert.Equal(t, []string{"[redacted]"}, redactHeaderValuesForLog("X-Forwarded-Access-Token", []string{"jwt"}))
+	assert.Equal(t, []string{"https"}, redactHeaderValuesForLog("X-Forwarded-Proto", []string{"https"}))
+}

+ 8 - 1
service/internal/tpl/templates.go

@@ -24,6 +24,8 @@ func jsonFunc(v any) (string, error) {
 	return string(data), nil
 }
 
+// Root template (funcs/options). parseTemplate clones before Parse — text/template
+// must not receive concurrent Parse calls on the same instance.
 var tpl = template.New("tpl").
 	Option("missingkey=error").
 	Funcs(template.FuncMap{"Json": jsonFunc})
@@ -179,7 +181,12 @@ func checkMissingArgumentError(err error) (bool, string) {
 }
 
 func parseTemplate(source string, data any) (string, error) {
-	t, err := tpl.Parse(source)
+	clone, err := tpl.Clone()
+	if err != nil {
+		return "", err
+	}
+
+	t, err := clone.Parse(source)
 
 	if err != nil {
 		return "", err