James Read 3 hafta önce
ebeveyn
işleme
1a31c4dee4
100 değiştirilmiş dosya ile 1835 ekleme ve 703 silme
  1. 2 1
      .github/PULL_REQUEST_TEMPLATE.md
  2. 3 0
      .github/workflows/build-and-release.yml
  3. 3 0
      .github/workflows/codestyle.yml
  4. 7 0
      .pre-commit-config.yaml
  5. 4 1
      Makefile
  6. 13 8
      config.yaml
  7. 5 3
      docs/modules/ROOT/pages/action_execution/aftercompletion.adoc
  8. 34 13
      docs/modules/ROOT/pages/action_execution/onwebhook.adoc
  9. 15 3
      docs/modules/ROOT/pages/action_execution/shellvsexec.adoc
  10. 2 0
      docs/modules/ROOT/pages/advanced_configuration/config_envs.adoc
  11. 2 0
      docs/modules/ROOT/pages/advanced_configuration/webui.adoc
  12. 2 0
      docs/modules/ROOT/pages/args/input.adoc
  13. 2 0
      docs/modules/ROOT/pages/args/input_checkbox.adoc
  14. 3 3
      docs/modules/ROOT/pages/args/input_checklist.adoc
  15. 7 2
      docs/modules/ROOT/pages/args/input_textarea.adoc
  16. 18 0
      docs/modules/ROOT/pages/args/intro.adoc
  17. 5 4
      docs/modules/ROOT/pages/args/password.adoc
  18. 7 1
      docs/modules/ROOT/pages/args/regex.adoc
  19. 2 0
      docs/modules/ROOT/pages/args/templates.adoc
  20. 35 19
      docs/modules/ROOT/pages/args/types.adoc
  21. 33 0
      docs/modules/ROOT/pages/dashboards/intro.adoc
  22. 2 0
      docs/modules/ROOT/pages/entities/intro.adoc
  23. 4 6
      docs/modules/ROOT/pages/reference/multiple_instances.adoc
  24. 10 4
      docs/modules/ROOT/pages/reference/network-ports.adoc
  25. 7 5
      docs/modules/ROOT/pages/reference/reference_themes_for_users.adoc
  26. 4 1
      frontend/Makefile
  27. 29 29
      frontend/package-lock.json
  28. 2 2
      frontend/package.json
  29. 26 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  30. 0 0
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  31. 85 15
      frontend/resources/vue/App.vue
  32. 2 0
      frontend/resources/vue/components/ActionIconGlyph.vue
  33. 2 0
      frontend/resources/vue/components/DashboardComponentDirectory.vue
  34. 2 0
      frontend/resources/vue/components/DashboardComponentDisplay.vue
  35. 21 0
      frontend/resources/vue/utils/logsFilterStorage.js
  36. 8 23
      frontend/resources/vue/views/ArgumentForm.vue
  37. 1 1
      frontend/resources/vue/views/DiagnosticsView.vue
  38. 2 2
      frontend/resources/vue/views/ExecutionView.vue
  39. 14 2
      frontend/resources/vue/views/LogsCalendarView.vue
  40. 40 2
      frontend/resources/vue/views/LogsListView.vue
  41. 4 1
      integration-tests/lib/elements.js
  42. 6 0
      lang/combined_output.json
  43. 1 0
      lang/de-DE.yaml
  44. 1 0
      lang/en.yaml
  45. 1 0
      lang/es-ES.yaml
  46. 1 0
      lang/it-IT.yaml
  47. 1 0
      lang/zh-Hans-CN.yaml
  48. 1 0
      lang/zh-Hant-TW.yaml
  49. 6 0
      proto/olivetin/api/v1/olivetin.proto
  50. 121 0
      service/.golangci.yml
  51. 11 10
      service/cmd/config-tool/main.go
  52. 190 123
      service/gen/olivetin/api/v1/olivetin.pb.go
  53. 1 1
      service/go.mod
  54. 1 1
      service/internal/acl/acl.go
  55. 2 2
      service/internal/acl/acl_test.go
  56. 62 21
      service/internal/api/api.go
  57. 1 1
      service/internal/api/apiActions.go
  58. 3 3
      service/internal/api/api_log_arguments.go
  59. 2 2
      service/internal/api/api_log_arguments_test.go
  60. 67 14
      service/internal/api/api_test.go
  61. 55 0
      service/internal/api/dashboard_acl_test.go
  62. 1 1
      service/internal/api/dashboards.go
  63. 5 8
      service/internal/auth/authpublic/authenticateduser.go
  64. 1 1
      service/internal/auth/authpublic/authenticateduser_test.go
  65. 5 5
      service/internal/auth/local_bearer_test.go
  66. 3 3
      service/internal/auth/otjwt/jwt.go
  67. 34 16
      service/internal/auth/otjwt/jwt_test.go
  68. 14 3
      service/internal/auth/otoauth2/restapi_auth_oauth2.go
  69. 1 1
      service/internal/auth/otoauth2/restapi_auth_oauth2_test.go
  70. 90 92
      service/internal/config/config.go
  71. 77 15
      service/internal/config/config_reloader.go
  72. 82 0
      service/internal/config/port_env_test.go
  73. 3 8
      service/internal/config/sanitize.go
  74. 4 4
      service/internal/config/source_file.go
  75. 1 1
      service/internal/entities/entities.go
  76. 5 6
      service/internal/entities/storage.go
  77. 4 4
      service/internal/executor/arguments.go
  78. 91 87
      service/internal/executor/arguments_test.go
  79. 131 76
      service/internal/executor/executor.go
  80. 127 0
      service/internal/executor/executor_test.go
  81. 0 1
      service/internal/executor/executor_unix.go
  82. 2 1
      service/internal/executor/timeout_context.go
  83. 3 3
      service/internal/filehelper/file_change_notify.go
  84. 7 2
      service/internal/httpservers/frontend.go
  85. 14 2
      service/internal/httpservers/prometheus.go
  86. 6 8
      service/internal/httpservers/webuiServer.go
  87. 1 1
      service/internal/installationinfo/runtimeinfo.go
  88. 2 2
      service/internal/installationinfo/server_diagnostics.go
  89. 2 2
      service/internal/logfilter/record.go
  90. 1 1
      service/internal/servicehost/log_directory_test.go
  91. 0 1
      service/internal/servicehost/servicehost_nonwin.go
  92. 1 1
      service/internal/tpl/templates.go
  93. 9 3
      service/internal/updatecheck/updateCheck.go
  94. 3 3
      service/internal/webhooks/jsonpath.go
  95. 1 2
      service/internal/webhooks/matcher.go
  96. 2 2
      service/internal/webhooks/matcher_justification_test.go
  97. 1 1
      service/main.go
  98. 13 7
      service/scripts/find-flakey-tests-inf/main.go
  99. 50 0
      specs/config-issues.md
  100. 35 0
      specs/dashboard-nav-categories.md

+ 2 - 1
.github/PULL_REQUEST_TEMPLATE.md

@@ -22,6 +22,7 @@ Please put a X in the boxes as evidence of reading through the checklist.
 - [ ] `make -wC service compile` runs without any issues.
 - [ ] `make -wC service codestyle` runs without any issues.
 - [ ] `make -wC service unittests` runs without any issues.
-- [ ] `make -wC webui codestyle` runs without any issues.
+- [ ] `make -wC frontend codestyle` runs without any issues.
+- [ ] `make -w frontend-unittests` runs without any issues.
 - [ ] `make -w it` runs without any issues.
 - [ ] I understand and accept the [AGPL-3.0 license](LICENSE) and [code of conduct](CODE_OF_CONDUCT.md), and my contributions fall under these.

+ 3 - 0
.github/workflows/build-and-release.yml

@@ -104,6 +104,9 @@ jobs:
       - name: unit tests
         run: make -w service-unittests
 
+      - name: frontend unit tests
+        run: make -w frontend-unittests
+
       - name: build service
         run: make -w service
 

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

@@ -38,3 +38,6 @@ jobs:
 
       - name: frontend
         run: make -wC frontend codestyle
+
+      - name: frontend unit tests
+        run: make -wC frontend unittests

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

@@ -47,6 +47,13 @@ repos:
         pass_filenames: false
         files: ^(frontend/|Makefile)
 
+      - id: frontend-unittests
+        name: frontend-unittests
+        entry: make frontend-unittests
+        language: system
+        pass_filenames: false
+        files: ^(frontend/|Makefile)
+
       - id: service-unittests
         name: service-unittests
         entry: make service-unittests

+ 4 - 1
Makefile

@@ -23,6 +23,9 @@ service-codestyle:
 frontend-codestyle:
 	$(MAKE) -wC frontend codestyle
 
+frontend-unittests:
+	$(MAKE) -wC frontend unittests
+
 it:
 	$(MAKE) -wC integration-tests
 
@@ -76,4 +79,4 @@ config-tool:
 devcheck:
 	python3 scripts/devcheck.py $(ARGS)
 
-.PHONY: proto service windows-resources windows-msi devcheck
+.PHONY: proto service windows-resources windows-msi frontend-unittests devcheck

+ 13 - 8
config.yaml

@@ -2,7 +2,8 @@
 # one port (this is called the "Single HTTP Frontend") and means you just need
 # one open port in the container/firewalls/etc.
 #
-# Listen on all addresses available, port 1337
+# Listen on all addresses available, port 1337.
+# If the PORT environment variable is set, it overrides this port (host is kept).
 listenAddressSingleHTTPFrontend: 0.0.0.0:1337
 
 # Choose from INFO (default), WARN and DEBUG
@@ -73,7 +74,7 @@ actions:
     icon: backup
     onclick: execution-dialog
     # https://docs.olivetin.app/action_execution/oncalendar.html
-    execOnCalendarFile: examples/demo-olivetin-calendar.yaml
+    # execOnCalendarFile: examples/demo-olivetin-calendar.yaml
 
   - title: Verify backup archive
     shell: sleep 3 && echo "Backup archive verified"
@@ -145,7 +146,7 @@ actions:
         title: Are you sure?!
 
   # Checklist arguments let users pick multiple predefined options. Selected
-  # values are passed to the action as a comma-separated string.
+  # values are passed to the action as a JSON array string.
   #
   # Docs: https://docs.olivetin.app/args/input_checklist.html
   - title: Backup selected directories
@@ -165,7 +166,7 @@ actions:
             value: music
           - title: Videos
             value: videos
-        default: documents,photos
+        default: '["documents","photos"]'
 
   # This is an action that runs a script included with OliveTin, that will
   # download themes. You will still need to set theme "themeName" in your config.
@@ -262,8 +263,8 @@ actions:
     icon: ping
     # https://docs.olivetin.app/action_execution/onfilecreated.html
     # mkdir -p /tmp/olivetin-demo-file-created
-    execOnFileCreatedInDir:
-      - /tmp/olivetin-demo-file-created
+    # execOnFileCreatedInDir:
+    #   - /tmp/olivetin-demo-file-created
 
   - title: Start {{ .CurrentEntity.Names }}
     icon: box
@@ -352,8 +353,10 @@ actionGroups:
 #
 # Docs: https://docs.olivetin.app/dashboards/intro.html
 dashboards:
-  # Top level items are dashboards.
+  # Top level items are dashboards. Optional `category` groups them under
+  # collapsible headings in the sidebar (same label = same group).
   - title: My Servers
+    category: Infrastructure
     contents:
       - title: All Servers
         type: fieldset
@@ -399,8 +402,10 @@ dashboards:
             contents:
               - title: '{{ server.name }} Print server name'
 
-  # This is the second dashboard.
+  # Second dashboard — same category as My Servers, so both appear under
+  # "Infrastructure" in the sidebar.
   - title: My Containers
+    category: Infrastructure
     contents:
       - title: 'Container {{ .CurrentEntity.Names }} ({{ .CurrentEntity.Image }})'
         entity: container

+ 5 - 3
docs/modules/ROOT/pages/action_execution/aftercompletion.adoc

@@ -13,13 +13,15 @@ actions:
     shellAfterCompleted: "apprise -c /config/apprise.yml -t 'Notification: Backup script completed' -b \"$(printf 'Backup completed with exit code %s. Log: %s' {{ exitCode }} {{ output }})\""
 ----
 
-When running shellAfterCompleted, you *cannot* use argument values - they are not passed to the command. However the following special arguments are defined;
+When running shellAfterCompleted, you *cannot* use user-defined argument values - they are not passed to the command. However the following special arguments are defined;
 
-* `{{ exitCode }}` - The exit code of the previous shell command. OliveTin substitutes this with the `EXITCODE` environment variable when running `shellAfterCompleted`, so shell metacharacters in the value cannot break quoting.
-* `{{ output }}` - The standard output of the previous shell command. OliveTin substitutes this with the `OUTPUT` environment variable when running `shellAfterCompleted`, so shell metacharacters in command output cannot be executed. You can also reference `$OUTPUT` directly in your `shellAfterCompleted` command. Do not place these placeholders inside single-quoted shell arguments; single quotes prevent `$OUTPUT` and `$EXITCODE` from expanding after substitution.
+* `{{ exitCode }}` / `{{ .Arguments.exitCode }}` - The exit code of the previous command. OliveTin rewrites these placeholders to the quoted `"$EXITCODE"` environment reference when running `shellAfterCompleted`, so shell metacharacters in the value cannot break quoting. Placeholders inside single-quoted shell arguments are rewritten so the environment reference can still expand.
+* `{{ output }}` / `{{ .Arguments.output }}` - The standard output of the previous command. OliveTin rewrites these placeholders to the quoted `"$OUTPUT"` environment reference, so shell metacharacters in command output cannot be executed. You can also reference `$OUTPUT` directly in your `shellAfterCompleted` command. Placeholders inside single-quoted shell arguments are rewritten so the environment reference can still expand.
 * `{{ .Arguments.ot_executionTrackingId }}` - The unique execution tracking id for this execution (version 3k; in 2k use `{{ ot_executionTrackingId }}`)
 * `{{ .Arguments.ot_username }}` - The username of the user who started the execution (version 3k; in 2k use `{{ ot_username }}`). May be `guest` or `cron` for unauthenticated or automated runs.
 
+Webhooks cannot use `shellAfterCompleted` (or `shell:`). Use `exec:` for webhook-triggered actions without an after-completion shell. See xref:action_execution/shellvsexec.adoc[Shell vs Exec].
+
 You can only use a single `shellAfterCompleted`, so use it for notifications, or similar. It would be an antipattern to use this do run 2 commands making up a mini script.
 
 The official OliveTin container images from version 2024.03.24 onwards include the fantastic apprise tool, which makes chat notifications on many protocols very easy.

+ 34 - 13
docs/modules/ROOT/pages/action_execution/onwebhook.adoc

@@ -7,7 +7,7 @@ OliveTin provides a dedicated webhook endpoint at `/webhooks` that can receive w
 
 == Basic Configuration
 
-To configure an action to run on a webhook, add the `execOnWebhook` property to your action:
+To configure an action to run on a webhook, add the `execOnWebhook` property to your action. Webhook-triggered actions **must** use `exec:` (not `shell:` or `shellAfterCompleted`).
 
 [source,yaml]
 .`config.yaml`
@@ -15,7 +15,8 @@ To configure an action to run on a webhook, add the `execOnWebhook` property to
 actions:
   - title: Deploy Application
     id: deploy
-    shell: /opt/scripts/deploy.sh
+    exec:
+      - /opt/scripts/deploy.sh
     execOnWebhook:
       - matchHeaders:
           X-Event-Type: deploy
@@ -51,7 +52,9 @@ Match webhooks based on HTTP header values:
 ----
 actions:
   - title: Process Event
-    shell: echo "Processing event"
+    exec:
+      - echo
+      - "Processing event"
     execOnWebhook:
       - matchHeaders:
           X-Event-Type: my-event
@@ -68,7 +71,9 @@ Match webhooks based on URL query parameters:
 ----
 actions:
   - title: Process Request
-    shell: echo "Processing request for {{ service }}"
+    exec:
+      - echo
+      - "Processing request for {{ service }}"
     arguments:
       - name: service
         type: ascii
@@ -76,9 +81,11 @@ actions:
       - matchQuery:
           action: deploy
           env: production
+        extract:
+          service: "$.service"
 ----
 
-A request to `/webhooks?action=deploy&env=production` would match this action.
+A request to `/webhooks?action=deploy&env=production` with a JSON body containing `"service"` would match this action and pass that field into the `service` argument.
 
 === Match by JSON Body Path
 
@@ -88,12 +95,16 @@ Match webhooks based on values in the JSON request body using JSONPath expressio
 ----
 actions:
   - title: Handle Push Event
-    shell: echo "Push to {{ branch }}"
+    exec:
+      - echo
+      - "Push to {{ branch }}"
     arguments:
       - name: branch
         type: ascii
     execOnWebhook:
       - matchPath: "$.event_type=push"
+        extract:
+          branch: "$.branch"
 ----
 
 The `matchPath` format is `jsonpath=value`. You can also just specify a JSONPath without a value to match if the path exists:
@@ -112,7 +123,9 @@ Header and query parameter values can use regex patterns by prefixing with `rege
 ----
 actions:
   - title: Handle Multiple Events
-    shell: echo "Handling event"
+    exec:
+      - echo
+      - "Handling event"
     execOnWebhook:
       - matchHeaders:
           X-Event-Type: "regex:^(push|pull_request|release)$"
@@ -126,7 +139,9 @@ You can combine multiple match criteria. All criteria must match for the webhook
 ----
 actions:
   - title: Production Deploy
-    shell: /opt/scripts/deploy.sh production
+    exec:
+      - /opt/scripts/deploy.sh
+      - production
     execOnWebhook:
       - matchHeaders:
           X-Event-Type: deploy
@@ -143,9 +158,10 @@ You can extract values from the webhook payload and pass them as arguments to yo
 ----
 actions:
   - title: Deploy Version
-    shell: |
-      echo "Deploying version {{ version }} to {{ environment }}"
-      /opt/scripts/deploy.sh "{{ version }}" "{{ environment }}"
+    exec:
+      - /opt/scripts/deploy.sh
+      - "{{ version }}"
+      - "{{ environment }}"
     arguments:
       - name: version
         type: ascii
@@ -176,7 +192,9 @@ For example, to access the `X-Request-Id` header in your action:
 ----
 actions:
   - title: Log Request
-    shell: echo "Request ID: {{ webhook_header_x-request-id }}"
+    exec:
+      - echo
+      - 'Request ID: {{ index .Arguments "webhook_header_x-request-id" }}'
     arguments:
       - name: webhook_header_x-request-id
         type: ascii
@@ -185,6 +203,8 @@ actions:
           X-Event-Type: log
 ----
 
+Header names that contain hyphens become argument keys with the same hyphens (for example `webhook_header_x-request-id`). Use the `index` map-lookup form shown above, because Go templates treat hyphens in bare identifiers as subtraction.
+
 == Webhook Authentication
 
 OliveTin supports several authentication methods to verify webhook requests:
@@ -278,7 +298,8 @@ An action can have multiple webhook configurations. The action will be triggered
 ----
 actions:
   - title: Deploy
-    shell: /opt/scripts/deploy.sh
+    exec:
+      - /opt/scripts/deploy.sh
     execOnWebhook:
       - matchHeaders:
           X-Event-Type: deploy-manual

+ 15 - 3
docs/modules/ROOT/pages/action_execution/shellvsexec.adoc

@@ -1,13 +1,25 @@
 = Shell vs Exec
 
-OliveTin supports two different methods to run commands: `shell` and `exec`. The difference between these two is that "shell" accepts strings, and will wrap that whole command in a shell with "bash -c". Exec uses a syscall directly to execute commands.
+OliveTin supports two different methods to run commands: `shell` and `exec`. The difference between these two is that "shell" accepts a single string and runs it via the system shell (`sh -c` on Unix; `cmd /C` on Windows). Exec passes an argument vector directly to the operating system without invoking a shell.
 
 * **Shell** is more flexible, because it allows you to chain commands (eg, using &&) and redirect or pipe output (eg: ">" or "|").
 * **Exec** is more secure, because it does not invoke a shell, and thus avoids shell injection attacks.
 
-Shell can be safe and secure with simple argument types (like ascii_identifier), but some argument types like URL can contain basically any character - /, :, ?, &, etc - which can lead to shell injection vulnerabilities while still being a valid URL.
+Shell can be safe and secure with simple argument types (like `ascii_identifier`), but some argument types like `url` can contain characters such as `/`, `:`, `?`, and `&` which can lead to shell injection vulnerabilities while still being a valid URL.
 
-OliveTin will try and prevent you from using dangerous characters in shell commands (eg, URL is no longer permitted with Shell).
+OliveTin blocks unsafe argument types from being used with `shell:` (for example `url`, `email`, `password`, `regex:...`, and raw string types). See xref:args/types.adoc#shell-blocked-arg-types[Types that cannot be used with shell]. Prefer `exec:` when in doubt.
+
+[#shell-entity-env-trust]
+== Entity and `.Env` values are not shell-sanitized
+
+User-supplied **argument** values are type-checked (and some types are blocked with `shell`) to reduce shell injection risk. That protection does **not** apply to:
+
+* Entity fields — `{{ .CurrentEntity.field }}` (and legacy forms such as `{{ server.hostname }}`)
+* Process environment — `{{ .Env.VAR_NAME }}`
+
+Those values are substituted into `shell` / `shellAfterCompleted` as-is. OliveTin assumes they are **server-controlled** (entity files and the OliveTin process environment under the operator's control). The author of the config is responsible for ensuring that data is trustworthy, or for using `exec` and careful quoting when it might not be.
+
+Webhooks cannot use `shell:` or `shellAfterCompleted`; webhook-triggered actions must use `exec:` only. See xref:action_execution/onwebhook.adoc[Execute on webhook].
 
 The way that you specify these two types of execution is different - `shell` expects a single string, while `exec` expects a list of strings (the first being the command, the rest being the arguments).
 

+ 2 - 0
docs/modules/ROOT/pages/advanced_configuration/config_envs.adoc

@@ -51,4 +51,6 @@ actions:
 
 `.Env` uses the same Go template context as other action variables (e.g. `.Arguments`, `.CurrentEntity`, `.OliveTin`). The map is built from the process environment when OliveTin starts; values are read at template execution time. If a variable is missing, the template engine will report a missing-key error (with `missingkey=error`), so use defaulting when a variable might be unset, e.g. `{{ or .Env.OPTIONAL_VAR "default" }}`. For template functions such as JSON encoding, see xref:args/templates.adoc#json-encoding[JSON encoding with Json].
 
+`.Env` values are **not** sanitized for shell safety. When you use them in `shell` or `shellAfterCompleted`, OliveTin assumes the process environment is server-controlled and that you accept responsibility for those values. See xref:action_execution/shellvsexec.adoc#shell-entity-env-trust[Entity and .Env values are not shell-sanitized].
+
 This feature addresses the need to use environment variables in templates without changing the config loader (see link:https://github.com/OliveTin/OliveTin/issues/840[GitHub issue #840]).

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

@@ -67,6 +67,8 @@ showNavigateOnStartIcons: false
 
 image::advanced_configuration/webui/sidebar/sidebar.png[]
 
+Root dashboards can optionally set a `category` field so the sidebar groups them under collapsible headings. See xref:dashboards/intro.adoc#categorize-dashboards-in-the-sidebar[Categorize dashboards in the sidebar].
+
 === Topbar navigation style
 
 `sectionNavigationStyle: topbar` looks like this;

+ 2 - 0
docs/modules/ROOT/pages/args/input.adoc

@@ -32,6 +32,8 @@ actions:
         type: ascii_sentence
 ----
 
+`description` is optional help text under the field. It is rendered as raw HTML; see xref:args/intro.adoc#arg-descriptions[Argument descriptions].
+
 This will give you a normal button, like this;
 
 image::args/input/args1.png[]

+ 2 - 0
docs/modules/ROOT/pages/args/input_checkbox.adoc

@@ -3,6 +3,8 @@
 
 The `checkbox` type argument is a simple checkbox that can be used to toggle a boolean value. It can be especially useful to pass flags to your actions.
 
+Define `choices` for the checked/unchecked values. A checkbox **without** choices is not allowed with `shell:` (use `exec:` instead).
+
 [source,yaml]
 ----
 actions:

+ 3 - 3
docs/modules/ROOT/pages/args/input_checklist.adoc

@@ -1,7 +1,7 @@
 [#checklist]
 = Input: Checklist
 
-The `checklist` type argument renders multiple checkboxes from predefined `choices`. Users can select one or more options, and the selected values are passed to your action as a comma-separated string.
+The `checklist` type argument renders multiple checkboxes from predefined `choices`. Users can select one or more options, and the selected values are passed to your action as a **JSON array string** (for example `["documents","photos"]`). Legacy comma-separated values are rejected.
 
 [source,yaml]
 ----
@@ -53,7 +53,7 @@ arguments:
 
 == Choice values
 
-Choice `value` fields must not contain commas, because commas are used to join multiple selections together.
+Choice `value` fields may contain commas; selections are encoded as JSON, not joined with commas.
 
 Each `title` is shown in the web interface. If a submitted segment matches a choice `title`, OliveTin maps it to the corresponding `value` before validation, matching the behaviour of xref:args/input_checkbox.adoc[checkbox] arguments with choices.
 
@@ -80,4 +80,4 @@ entities:
     name: container
 ----
 
-OliveTin expands the template once per entity instance and renders each result as a checkbox. Selected values are still passed as a comma-separated string.
+OliveTin expands the template once per entity instance and renders each result as a checkbox. Selected values are still passed as a JSON array string.

+ 7 - 2
docs/modules/ROOT/pages/args/input_textarea.adoc

@@ -3,19 +3,24 @@
 
 OliveTin supports multi-line text inputs, which can be useful for longer messages or scripts. You should set your argument `type` to `raw_string_multiline` to use these.
 
-As the name implies, textareas are raw, and are NOT validated by any regex.
+As the name implies, textareas are raw, and are NOT validated by any regex. For that reason they **cannot** be used with `shell:` — use `exec:` so the value is a separate argv element.
 
 [source,yaml]
 .`config.yaml`
 ----
 actions:
   - title: Save text to file
-    shell: echo "$CONTENT" > file
+    exec:
+      - /bin/sh
+      - -c
+      - echo "$CONTENT" > file
     arguments:
       - type: raw_string_multiline
         name: content
 ----
 
+In that `exec:` example, `$CONTENT` comes from the process environment (OliveTin exports each argument as an uppercase env var), not from shell-string interpolation of the argument into `shell:`.
+
 This renders like this;
 
 image::args/textarea/multiline-text.png[]

+ 18 - 0
docs/modules/ROOT/pages/args/intro.adoc

@@ -10,6 +10,24 @@ Examples of valid argument names are `{{ personName }}`, `{{ customer_number }}`
 * numbers are allowed (argument names can also start with numbers)
 * all other characters are invalid for argument names.
 
+[#arg-descriptions]
+== Argument descriptions
+
+Each argument can include a `description:` shown under the field on the argument form. OliveTin renders that value as **raw HTML**, so you can use markup such as links, line breaks, or emphasis:
+
+[source,yaml]
+----
+arguments:
+  - name: host
+    title: Hostname
+    type: ascii_identifier
+    description: |
+      Enter a host OliveTin can reach.
+      See the <a href="https://example.com/docs" target="_blank" rel="noopener noreferrer">network guide</a>.
+----
+
+Treat `description` as trusted markup that you control (the same trust model as xref:dashboards/4-displays.adoc[dashboard displays]). Do not put untrusted or entity-derived strings into it without careful escaping.
+
 == What's Next?
 
 Now that you understand how arguments work, explore the different argument types and features:

+ 5 - 4
docs/modules/ROOT/pages/args/password.adoc

@@ -1,9 +1,9 @@
 = Password
 
-Sometimes you want to mask the input you pass, and a password field is useful for this. 
+Sometimes you want to mask the input you pass, and a password field is useful for this.
 
 [WARNING]
-Passwords are passed to the OliveTin server in cleartext (unless you're using HTTPS), and are just treated as a string on the server side. 
+Passwords are passed to the OliveTin server in cleartext (unless you're using HTTPS), and are just treated as a string on the server side. Password arguments are **not** type-checked for allowed characters, and they **cannot** be used with `shell:` — use `exec:` so the value is passed as a separate argument instead of being interpolated into a shell string.
 
 [source,yaml]
 .`config.yaml`
@@ -11,9 +11,10 @@ Passwords are passed to the OliveTin server in cleartext (unless you're using HT
 actions:
   - title: echo a message
     icon: smile
-    shell: echo {{ my_password }}
+    exec:
+      - echo
+      - "{{ my_password }}"
     arguments:
       - name: my_password
         type: password
 ----
-

+ 7 - 1
docs/modules/ROOT/pages/args/regex.adoc

@@ -5,13 +5,17 @@ OliveTin version 2024.02.081 and above support custom regex patterns for argumen
 
 NOTE: The regex pattern should be enclosed in single quotes, otherwise you will probably get a YAML error when starting OliveTin.
 
+Custom `regex:...` argument types **cannot** be used with `shell:`. Use `exec:` instead (see xref:action_execution/shellvsexec.adoc[Shell vs Exec]).
+
 [source,yaml]
 .`config.yaml`
 ----
 actions:
   - title: echo a message
     icon: smile
-    shell: echo "{{ message }}"
+    exec:
+      - echo
+      - "{{ message }}"
     arguments:
       - name: message
         type: 'regex:^\w\w\w$'
@@ -22,4 +26,6 @@ The site http://regex101.com is a good place to test your regex patterns. OliveT
 . **Regex in the browser** (which probably uses PCRE or Perl Compatible Regular Expressions) - this is so that the browser can give you a nice validation message. This is ignored when it reaches the server though, or if you are using the API directly. Select "PCRE" on the regex101 site when testing.
 . **Regex on the server** (which uses Golang's regex engine) - this is the one that actually validates the input. Select "Golang" on the regex101 site when testing.
 
+On the server, the pattern you provide is anchored to the full string (`^(?:…)$`), so a partial match is not enough.
+
 You cannot specify different regex patterns for the browser and server. The regex pattern you create will need to be compatible with both types of regex engine.

+ 2 - 0
docs/modules/ROOT/pages/args/templates.adoc

@@ -10,6 +10,8 @@ In OliveTin 3k, use dotted names for template context variables:
 * `{{ .Env.VAR_NAME }}` — process environment (see xref:advanced_configuration/config_envs.adoc#using-env-in-template-replacements[Using .Env in template replacements])
 * `{{ .OliveTin.Build.Version }}` and related build/runtime fields
 
+IMPORTANT: Unlike argument values, `.CurrentEntity` and `.Env` are **not** sanitized for shell safety when used in `shell` or `shellAfterCompleted`. They are treated as server-controlled data; the config author is responsible for that trust. See xref:action_execution/shellvsexec.adoc#shell-entity-env-trust[Entity and .Env values are not shell-sanitized].
+
 In OliveTin 2k, argument and execution-request placeholders used the shorter form (for example, `{{ message }}` instead of `{{ .Arguments.message }}`).
 
 [#json-encoding]

+ 35 - 19
docs/modules/ROOT/pages/args/types.adoc

@@ -7,29 +7,45 @@ A full list of argument types are below;
 [%header,cols="1,0,2"]
 |===
 | Type                        | Rendered as                       | Allowed values
-| (default)                   | xref:args/input.adoc[Textbox]           | If a `type:` is not set, and `choices:` is empty, then ascii will be used, and a warning will be logged. It is recommended that you set the type explicitly, rather than relying on defaults.
-| ascii                       | xref:args/input.adoc[Textbox]           | a-z (case insensitive), 0-9, but no spaces or punctuation
-| ascii_identifier            | xref:args/input.adoc[Textbox]           | Like a DNS name, a-Z (case insensitive), 0-9, `-`, `.`, and `_`.
+| (default)                   | xref:args/input.adoc[Textbox]           | If a `type:` is not set, and `choices:` is empty, then `ascii` will be used, and a config warning is reported. It is recommended that you set the type explicitly, rather than relying on defaults.
+| ascii                       | xref:args/input.adoc[Textbox]           | `a-z`, `A-Z`, `0-9` only. No spaces or punctuation.
+| ascii_identifier            | xref:args/input.adoc[Textbox]           | `a-z`, `A-Z`, `0-9`, `-`, `.`, and `_`.
 | dnsname                     | xref:args/input.adoc[Textbox]           | A DNS hostname (RFC 1123). Short names (e.g. `webserver`) and FQDNs (e.g. `webserver.example.com`). Letters/digits/hyphens only, no underscores. Optional trailing dot allowed.
 | shell_safe_identifier       | xref:args/input.adoc[Textbox]           | Like an ascii identifier, but also allows `@` and `+`. Useful for shell-safe usernames and email-style identifiers.
-| ascii_sentence              | xref:args/input.adoc[Textbox]           | a-z (case insensitive), 0-9, with spaces, `.` and `,`.
-| unicode_identifier          | xref:args/input.adoc[Textbox]           | Like an ascii identifier, but allows unicode characters. This is useful for languages that use non-ascii characters, such as Chinese, Japanese, etc.
-| email                       | xref:args/input.adoc[Textbox]           | An email address.
-| password                    | xref:args/password.adoc[Password]       | A password, which is hidden when typed.
-| very_dangerous_raw_string   | xref:args/input.adoc[Textbox]           | Anything. This is **incredibly dangerous**, as effectively people can type anything they like, including executing additional commands beyond what you specify. Absolutely should not be used unless your OliveTin instance can only be used by people you trust entirely.
-| regex:...                   | xref:args/input.adoc[Textbox]           | Version 2024.03.081 and above support custom regex patterns. See xref:args/regex.adoc[Custom regex arguments].
-| int                         | xref:args/input.adoc[Textbox]           | Any number, made up of the characters 0 to 9. Negative numbers are not supported.
-| url                         | xref:args/input.adoc[Textbox]           | A URL (e.g. https://example.com). Accepts any scheme, including `file://` and `ftp://`. See warning below.
+| ascii_sentence              | xref:args/input.adoc[Textbox]           | `a-z`, `A-Z`, `0-9`, spaces, `.`, `,`, `-`, and `_`.
+| unicode_identifier          | xref:args/input.adoc[Textbox]           | Same character class as Go's `\w` plus `-` and `.` (ASCII letters, digits, and `_`, plus `-` and `.`). Despite the name, non-ASCII letters are **not** accepted by the current server check.
+| email                       | xref:args/input.adoc[Textbox]           | An email address (parsed with Go's `mail.ParseAddress`).
+| password                    | xref:args/password.adoc[Password]       | Any string (not type-checked). Hidden in the UI. **Not allowed with `shell:`** — use `exec:`.
+| very_dangerous_raw_string   | xref:args/input.adoc[Textbox]           | Anything. This is **incredibly dangerous**, as effectively people can type anything they like, including executing additional commands beyond what you specify. Absolutely should not be used unless your OliveTin instance can only be used by people you trust entirely. **Not allowed with `shell:`** — use `exec:`.
+| regex:...                   | xref:args/input.adoc[Textbox]           | Custom regex patterns. See xref:args/regex.adoc[Custom regex arguments]. **Not allowed with `shell:`** — use `exec:`.
+| int                         | xref:args/input.adoc[Textbox]           | Digits `0-9` only. Negative numbers are not supported.
+| url                         | xref:args/input.adoc[Textbox]           | A URL with scheme `http` or `https` only (e.g. `https://example.com`). **Not allowed with `shell:`** — use `exec:`.
+| datetime                    | xref:args/input_datetime.adoc[Date & Time] | A local datetime in the form `YYYY-MM-DDTHH:MM:SS` (seconds may be mangled to `:00` when browsers omit them).
 | confirmation                | xref:args/input_confirmation.adoc[Confirmation] | A UI gate that requires a checkbox before starting. Usually unnamed (nothing is substituted). If named, the value is only `0` or `1`.
-| checklist                   | xref:args/input_checklist.adoc[Checklist]     | Multiple checkboxes from predefined choices. Selected values are passed as a comma-separated string.
-| n/a, but `choices` used     | xref:args/input_dropdown.adoc[Dropdown]         | A "hidden" argument that makes the action require a confirmation before launching.
-| raw_string_multiline        | xref:args/input_textarea.adoc[Textarea]         | Anything. This is **dangerous**, as effectively people can type anything they like
+| checkbox                    | xref:args/input_checkbox.adoc[Checkbox] | Typically used with `choices` for on/off flag values. A checkbox **without** choices is **not allowed with `shell:`** — use `exec:` or define choices.
+| checklist                   | xref:args/input_checklist.adoc[Checklist]     | Multiple checkboxes from predefined choices. Selected values are passed as a JSON array string (e.g. `["documents","photos"]`).
+| n/a, but `choices` used     | xref:args/input_dropdown.adoc[Dropdown]         | Predefined choices shown as a dropdown. The submitted value must match one of the choice values (or an entity-expanded choice).
+| raw_string_multiline        | xref:args/input_textarea.adoc[Textarea]         | Anything (not type-checked). **Dangerous**, and **not allowed with `shell:`** — use `exec:`.
 |===
 
-[WARNING]
-.Security risk: URL argument type
-====
-The `url` argument type does not restrict the URL scheme. Users can enter `file://` (local filesystem) URLs, `ftp://`, or other schemes. If the argument value is passed directly to curl, wget, or similar tools, a malicious or mistaken input could read local files, access internal services, or trigger unwanted network requests.
+[#shell-blocked-arg-types]
+== Types that cannot be used with `shell:`
+
+When an action uses `shell:` (including with arguments substituted into the command string), OliveTin rejects these argument types and asks you to use `exec:` instead:
+
+* `url`
+* `email`
+* `password`
+* `raw_string_multiline`
+* `very_dangerous_raw_string`
+* `html` (internal/display-oriented; skips normal type checks)
+* any custom `regex:...` type
+* `checkbox` when it has **no** `choices`
 
-If your action might be used by untrusted users, validate or filter the URL in your script (e.g. allow only `https://`) before using the value.
+See xref:action_execution/shellvsexec.adoc[Shell vs Exec].
+
+[NOTE]
+.URL schemes
+====
+The `url` type accepts only `http` and `https`. Schemes such as `file://`, `ftp://`, and `gopher://` are rejected by the server.
 ====

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

@@ -82,6 +82,39 @@ dashboards:
           - title: '{{ server.name }} Power Off'
 ----
 
+[#categorize-dashboards-in-the-sidebar]
+== Categorize dashboards in the sidebar
+
+When you have several root dashboards, you can group them under collapsible category headings in the sidebar with an optional `category` field on each root dashboard:
+
+[source,yaml]
+.`config.yaml`
+----
+dashboards:
+  - title: My Servers
+    category: Infrastructure
+    contents:
+      - title: Ping All Servers
+
+  - title: My Containers
+    category: Infrastructure
+    contents:
+      - title: Restart nginx
+
+  - title: Status Board
+    category: Monitoring
+    contents:
+      - title: Show uptime
+
+  - title: Misc Tools
+    contents:
+      - title: Hello World
+----
+
+Dashboards that share the same `category` value are grouped together. Category order follows the first time each category appears in the config among dashboards the user can see. Dashboards without a `category` appear above the category sections. Nested items under `contents` do not use `category`.
+
+Categories are shown when `sectionNavigationStyle` is `sidebar` (the default). See xref:advanced_configuration/webui.adoc#section-navgiation-style[Section Navigation Style].
+
 == What's Next?
 
 Now that you understand dashboards, explore these related features:

+ 2 - 0
docs/modules/ROOT/pages/entities/intro.adoc

@@ -11,6 +11,8 @@ Entities are just loaded from files on disk, OliveTin will also watch these file
 
 Entity data files can contain any fields you need. Those values are available in action templates as `{{ .CurrentEntity.field }}` — for example, `{{ .CurrentEntity.status }}` or `{{ .CurrentEntity.hostname }}`.
 
+Entity field values are **not** sanitized for shell safety. If you substitute them into `shell` or `shellAfterCompleted`, OliveTin assumes the entity files are server-controlled and that you accept responsibility for that data. See xref:action_execution/shellvsexec.adoc#shell-entity-env-trust[Entity and .Env values are not shell-sanitized].
+
 To control which fields appear in the Entities page table and entity details view, configure `properties` on the entity definition in `config.yaml`. See xref:entities/properties.adoc[Entity properties] for details.
 
 [source,yaml]

+ 4 - 6
docs/modules/ROOT/pages/reference/multiple_instances.adoc

@@ -12,13 +12,13 @@ This is the easiest way to run multiple OliveTin instances. Follow the xref:inst
 3. When creating the container, pass in the 2nd instance's config, eg; `-v /opt/OliveTin_two/:/config/`
 4. When creating the container, set the external port, eg: `2337:1337` - 2337 is the external port)
 
-You do not need to change the listenAddresses / ports for the other 3 ports that OliveTin uses, when you are running inside a container. 
+You do not need to change the listenAddresses / ports for the other 3 ports that OliveTin uses, when you are running inside a container.
 
 == Without containers - using a package (.tar.gz)
 
 If you are not using containers, then it is probably best not to use a `.deb/.rpm` installation, as those packages can only be installed for one instance.
 
-Instead, follow the instructions for xref:install/targz.adoc[installing from a .tar.gz] archive. 
+Instead, follow the instructions for xref:install/targz.adoc[installing from a .tar.gz] archive.
 
 When you come to create the config.yaml file, OliveTin will look for this in it's own startup directory. Therefore it is probably best to extract the .tar.gz file like this and change the paths;
 
@@ -28,7 +28,7 @@ When you come to create the config.yaml file, OliveTin will look for this in it'
 
 Because you are running outside of a container, you will also need to change the "internal" ports used by OliveTin so they are separate for all instances. OliveTin listens on 4 addresses (1 external, 3 internal) and needs 4 ports. You can read about these in the xref:reference/network-ports.adoc[network ports documentation].
 
-NOTE: OliveTin also supports reading the PORT environment variable, and will use this as a base port for the simgle frontend, will add 1 to start extra servers. For example of PORT is 2000, then the simgle frontend will start on port 2000, the REST API on 2001, and so on. 
+NOTE: If the `PORT` environment variable is set, OliveTin listens on that port for the single HTTP frontend only (overriding `listenAddressSingleHTTPFrontend` in the config, keeping the host from the config). When `PORT` is unset, the config value is used, or `0.0.0.0:1337` if that setting is omitted. `PORT` does not change the other internal listen addresses — set those explicitly in each instance's config as shown below.
 
 You could end up with a setup that looks like this;
 
@@ -40,7 +40,7 @@ You could end up with a setup that looks like this;
 | OliveTin_three         | `/opt/OliveTin_three` | `/opt/OliveTin_three/config.yaml` | `0.0.0.0:3337`           | `localhost:3338`    | `localhost:3339`      | `localhost:3340`
 |===
 
-Note that you will also need to adjust the default systemd service file to point to your install directory, if using that. Here is an example for `OliveTin_two`; 
+Note that you will also need to adjust the default systemd service file to point to your install directory, if using that. Here is an example for `OliveTin_two`;
 
 .A modified systemd service file for a 2nd instance
 ----
@@ -55,5 +55,3 @@ Restart=always
 [Install]
 WantedBy=multi-user.target
 ----
-
-

+ 10 - 4
docs/modules/ROOT/pages/reference/network-ports.adoc

@@ -4,7 +4,7 @@
 OliveTin might surprise some people when they see it is listening on several
 ports when it starts up. Most of these ports are internal and localhost-only by
 default. It keeps the architecture of OliveTin clean and simple, and allows for
-a lot of flexibility if needed. 
+a lot of flexibility if needed.
 
 == Network flow diagram
 
@@ -38,18 +38,24 @@ server.
 
 Below is a detailed reference table.
 
-== Port Reference Table  
+== Port Reference Table
 
 .Port reference table
 [%header,cols="1,2"]
 |===
-| Config file reference (and Default Address:Port) | Purpose                   
+| Config file reference (and Default Address:Port) | Purpose
 | `listenAddressSingleHTTPFrontend: 0.0.0.0:1337` (listen on all available addresses) | This is a "micro reverse proxy" built into OliveTin. It's only purpose is to serve /ui and / (the web interface) from a single endpoint. This means that problems like CORSs and setting "external addresses" is not necessary. It does not do any caching or anything else. It can be disabled, but it makes life a lot easier for you. It's common to put your own reverse proxy like haproxy, traefik, etc in front of this single micro reverse proxy.
 | `listenAddressRestActions: localhost:1338`       | REST - the protocol used by web pages to talk to web APIs. In the case of OliveTin, the API is used to get actions, and start actions.
 | `listenAddressGrpcActions:localhost:1339`        | gRPC - a very popular method of service-to-service API communication. This provides the "real" API for OliveTin.
 | `listenAddressWebUI: localhost:1340`             | Hosts a simple static web server with some HTML, stylesheets, Javascript etc for the web interface.
 | `listenAddressPrometheus: localhost:1341`        | Hosts a prometheus endpoint, which is disabled by default. See xref:advanced_configuration/prometheus.adoc[Prometheus] to learn more.
-|=== 
+|===
+
+== PORT environment variable
+
+If the `PORT` environment variable is set at startup, OliveTin uses it as the listen port for `listenAddressSingleHTTPFrontend` only, keeping the host from the config (default host `0.0.0.0`). This overrides an explicit port in `config.yaml`, which is useful on platforms that assign a port via `PORT` (for example Heroku or Cloud Run). Internal listen addresses (`listenAddressRestActions`, `listenAddressWebUI`, and so on) are not derived from `PORT`; configure those separately when needed.
+
+When `PORT` is not set, OliveTin uses `listenAddressSingleHTTPFrontend` from the config, or `0.0.0.0:1337` if that setting is omitted.
 
 == See also
 

+ 7 - 5
docs/modules/ROOT/pages/reference/reference_themes_for_users.adoc

@@ -8,11 +8,11 @@ You can look for themes on the link:http://www.olivetin.app/themes/[OliveTin The
 There are 3 ways to install a theme;
 
 
-If running inside a Docker container: 
+If running inside a Docker container:
 
 1. Use the `olivetin-get-theme` command to easily Git clone the theme into your `custom-webui/themes/` directory.
 
-If running without using containers: 
+If running without using containers:
 
 1. Download the theme .zip and copy it across to your `custom-webui/themes/` directory.
 2. Git Clone the theme into your `custom-webui/themes/` directory.
@@ -22,11 +22,14 @@ If running without using containers:
 
 The default OliveTin configuration comes with an action to get new OliveTin themes. If you deleted it from your configuration, you can add it back in by adding the following to your `config.yaml` file;
 
-[source,bash]
+[source,yaml]
 ----
 actions:
   - title: Get OliveTin Theme
-    shell: olivetin-get-theme {{ themeGitRepo }} {{ themeFolderName }}
+    exec:
+      - olivetin-get-theme
+      - "{{ themeGitRepo }}"
+      - "{{ themeFolderName }}"
     icon: theme
     arguments:
       - name: themeGitRepo
@@ -93,4 +96,3 @@ body {
 Profit.
 
 Check out xref:reference/reference_themes_for_developers.adoc[Themes for Developers] for more information on how to develop themes.
-

+ 4 - 1
frontend/Makefile

@@ -7,6 +7,9 @@ codestyle:
 	npx eslint --fix main.js js/* resources/vue
 	npx stylelint style.css
 
+unittests: deps
+	npm test
+
 clean:
 	$(call delete-files,dist)
 
@@ -18,4 +21,4 @@ build:
 
 dist: deps clean build
 
-.PHONY: codestyle
+.PHONY: codestyle unittests

+ 29 - 29
frontend/package-lock.json

@@ -18,12 +18,12 @@
 				"@xterm/addon-web-links": "^0.12.0",
 				"@xterm/xterm": "^6.0.0",
 				"iconify-icon": "^3.0.2",
-				"picocrank": "^1.21.1",
+				"picocrank": "^1.21.2",
 				"standard": "^17.1.2",
 				"unplugin-vue-components": "^32.1.0",
 				"vite": "^8.1.5",
 				"vue": "^3.5.40",
-				"vue-i18n": "^11.4.7",
+				"vue-i18n": "^11.4.8",
 				"vue-router": "^5.2.0"
 			},
 			"devDependencies": {
@@ -998,14 +998,14 @@
 			"license": "MIT"
 		},
 		"node_modules/@intlify/core-base": {
-			"version": "11.4.7",
-			"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.7.tgz",
-			"integrity": "sha512-MSB/sBKwEWJTILvQIhg2rnIcwPpLayo3wGwvVA+dJTNeUBD9GoqQgAaSOLdI9iOPDHCm9YoVnLqpfzza98MpkQ==",
+			"version": "11.4.8",
+			"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.8.tgz",
+			"integrity": "sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/devtools-types": "11.4.7",
-				"@intlify/message-compiler": "11.4.7",
-				"@intlify/shared": "11.4.7"
+				"@intlify/devtools-types": "11.4.8",
+				"@intlify/message-compiler": "11.4.8",
+				"@intlify/shared": "11.4.8"
 			},
 			"engines": {
 				"node": ">= 22"
@@ -1015,13 +1015,13 @@
 			}
 		},
 		"node_modules/@intlify/devtools-types": {
-			"version": "11.4.7",
-			"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.7.tgz",
-			"integrity": "sha512-GSz+J+hqH+AEpAHIYya6fSufS30OaMnG39HiZX7DmGKi3+aaLvassCfsXENEc4Wr4m68q2YP0QdMdB3D9UeAXg==",
+			"version": "11.4.8",
+			"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.8.tgz",
+			"integrity": "sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/core-base": "11.4.7",
-				"@intlify/shared": "11.4.7"
+				"@intlify/core-base": "11.4.8",
+				"@intlify/shared": "11.4.8"
 			},
 			"engines": {
 				"node": ">= 22"
@@ -1031,12 +1031,12 @@
 			}
 		},
 		"node_modules/@intlify/message-compiler": {
-			"version": "11.4.7",
-			"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.7.tgz",
-			"integrity": "sha512-bHxmh7n94N4N1evADeb7XTkc3jTw6Ki5biMFZVSX6Jmk+iehy8/maeH2XUsBI27rtKIK+Hzc6QnVAKggUwylKw==",
+			"version": "11.4.8",
+			"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.8.tgz",
+			"integrity": "sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/shared": "11.4.7",
+				"@intlify/shared": "11.4.8",
 				"source-map-js": "^1.0.2"
 			},
 			"engines": {
@@ -1047,9 +1047,9 @@
 			}
 		},
 		"node_modules/@intlify/shared": {
-			"version": "11.4.7",
-			"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.7.tgz",
-			"integrity": "sha512-OtjPZan3No2OZZFnMUiCVsXC6+j+XRwEywaFDk0AoayAbLuPesyDloXhJZLl9JUl5vHZeQUkYSbEA8VX+CWMjg==",
+			"version": "11.4.8",
+			"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.8.tgz",
+			"integrity": "sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==",
 			"license": "MIT",
 			"engines": {
 				"node": ">= 22"
@@ -5272,9 +5272,9 @@
 			"license": "ISC"
 		},
 		"node_modules/picocrank": {
-			"version": "1.21.1",
-			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.21.1.tgz",
-			"integrity": "sha512-VnfNFok7BkpLKymAIbD2zUA0bf/AiNBS63Xk2mfi8uXOkdAmf06pQaI0ssGBv2/S9p1jbCATWj9OFz1uqa39NQ==",
+			"version": "1.21.2",
+			"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.21.2.tgz",
+			"integrity": "sha512-bUw6789jyYCTa9waINvYRHmnfgtoMquUidkJx2yhrDCMGbu2O65Qilkl++57yl5JnFeoZOhiPuQTKMPxMQku4A==",
 			"license": "ISC",
 			"dependencies": {
 				"@hugeicons/core-free-icons": "^4.2.2",
@@ -7118,14 +7118,14 @@
 			}
 		},
 		"node_modules/vue-i18n": {
-			"version": "11.4.7",
-			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.7.tgz",
-			"integrity": "sha512-j6RyshdPPzqLiMAUpnpvZGFPM+rRoWi14Sl5yTsquvoW0/56DWyvhAj2o9TO2YXGvb6teg8T0xrYO9jR3urvdw==",
+			"version": "11.4.8",
+			"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.8.tgz",
+			"integrity": "sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==",
 			"license": "MIT",
 			"dependencies": {
-				"@intlify/core-base": "11.4.7",
-				"@intlify/devtools-types": "11.4.7",
-				"@intlify/shared": "11.4.7",
+				"@intlify/core-base": "11.4.8",
+				"@intlify/devtools-types": "11.4.8",
+				"@intlify/shared": "11.4.8",
 				"@vue/devtools-api": "^6.5.0"
 			},
 			"engines": {

+ 2 - 2
frontend/package.json

@@ -32,12 +32,12 @@
 		"@xterm/addon-web-links": "^0.12.0",
 		"@xterm/xterm": "^6.0.0",
 		"iconify-icon": "^3.0.2",
-		"picocrank": "^1.21.1",
+		"picocrank": "^1.21.2",
 		"standard": "^17.1.2",
 		"unplugin-vue-components": "^32.1.0",
 		"vite": "^8.1.5",
 		"vue": "^3.5.40",
-		"vue-i18n": "^11.4.7",
+		"vue-i18n": "^11.4.8",
 		"vue-router": "^5.2.0"
 	},
 	"engines": {

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

@@ -1890,6 +1890,11 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
    * @generated from field: int32 config_issue_count = 26;
    */
   configIssueCount: number;
+
+  /**
+   * @generated from field: repeated olivetin.api.v1.RootDashboard root_dashboard_entries = 27;
+   */
+  rootDashboardEntries: RootDashboard[];
 };
 
 /**
@@ -1898,6 +1903,27 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
  */
 export declare const InitResponseSchema: GenMessage<InitResponse>;
 
+/**
+ * @generated from message olivetin.api.v1.RootDashboard
+ */
+export declare type RootDashboard = Message<"olivetin.api.v1.RootDashboard"> & {
+  /**
+   * @generated from field: string title = 1;
+   */
+  title: string;
+
+  /**
+   * @generated from field: string category = 2;
+   */
+  category: string;
+};
+
+/**
+ * Describes the message olivetin.api.v1.RootDashboard.
+ * Use `create(RootDashboardSchema)` to create a new message.
+ */
+export declare const RootDashboardSchema: GenMessage<RootDashboard>;
+
 /**
  * @generated from message olivetin.api.v1.AdditionalLink
  */

Dosya farkı çok büyük olduğundan ihmal edildi
+ 0 - 0
frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js


+ 85 - 15
frontend/resources/vue/App.vue

@@ -338,40 +338,110 @@ function updateHeaderFromInit () {
   connectEventStreamIfNeeded()
 }
 
+function getRootDashboardEntries () {
+  const entries = window.initResponse?.rootDashboardEntries
+
+  if (entries && entries.length > 0) {
+    return entries.map((entry) => ({
+      title: entry.title,
+      category: entry.category || ''
+    }))
+  }
+
+  return (window.initResponse?.rootDashboards || []).map((title) => ({
+    title,
+    category: ''
+  }))
+}
+
+function addDashboardNavLink (title) {
+  navigation.value.addNavigationLink({
+    id: title,
+    name: title,
+    title,
+    path: title === 'Actions' ? '/' : `/dashboards/${title}`,
+    icon: DashboardSquare01Icon
+  })
+}
+
+function addCategorizedDashboardLinks (entries) {
+  const categoryOrder = []
+  const byCategory = new Map()
+
+  for (const entry of entries) {
+    const category = entry.category.trim()
+
+    if (!byCategory.has(category)) {
+      byCategory.set(category, [])
+      categoryOrder.push(category)
+    }
+
+    byCategory.get(category).push(entry.title)
+  }
+
+  for (const category of categoryOrder) {
+    navigation.value.addSection(category)
+
+    for (const title of byCategory.get(category)) {
+      addDashboardNavLink(title)
+    }
+  }
+}
+
 function renderNavigation () {
   if (!navigation.value) {
     return
   }
 
-  const rootDashboards = window.initResponse?.rootDashboards || []
-
   if (typeof navigation.value.clearNavigationLinks === 'function') {
     navigation.value.clearNavigationLinks()
   }
 
-  for (const rootDashboard of rootDashboards) {
-    navigation.value.addNavigationLink({
-      id: rootDashboard,
-      name: rootDashboard,
-      title: rootDashboard,
-      path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`,
-      icon: DashboardSquare01Icon
-    })
+  const entries = getRootDashboardEntries()
+  const uncategorized = entries.filter((entry) => !entry.category.trim())
+  const categorized = entries.filter((entry) => entry.category.trim())
+
+  for (const entry of uncategorized) {
+    addDashboardNavLink(entry.title)
   }
 
-  navigation.value.addSeparator()
-  navigation.value.addRouterLink('Entities', t('nav.entities'))
+  addCategorizedDashboardLinks(categorized)
+  addSystemNavLinks()
+}
+
+function addSystemNavLinks () {
+  const systemLinks = []
+
+  systemLinks.push({
+    routeName: 'Entities',
+    title: t('nav.entities')
+  })
 
   if (showLogs.value) {
-    navigation.value.addRouterLink('Logs', t('nav.logs'))
+    systemLinks.push({
+      routeName: 'Logs',
+      title: t('nav.logs')
+    })
   }
 
   if (showDiagnostics.value) {
     const issueCount = window.initResponse?.configIssueCount || 0
-    navigation.value.addRouterLink('Diagnostics', t('nav.diagnostics'), {
-      count: issueCount
+    systemLinks.push({
+      routeName: 'Diagnostics',
+      title: t('nav.diagnostics'),
+      options: { count: issueCount }
     })
   }
+
+  if (systemLinks.length === 0) {
+    return
+  }
+
+  navigation.value.addSection(t('nav.system'))
+
+  for (const link of systemLinks) {
+    navigation.value.addRouterLink(link.routeName, link.title, link.options || {})
+  }
 }
 
 function openLanguageDialog () {

+ 2 - 0
frontend/resources/vue/components/ActionIconGlyph.vue

@@ -7,10 +7,12 @@
       height="1em"
       class="action-icon-glyph-svg"
     />
+    <!-- eslint-disable vue/no-v-html -- intentional: action icons may use HTML / custom-webui <img> -->
     <span
       v-else-if="decodedTextGlyphIsHtml"
       v-html="decodedTextGlyph"
     />
+    <!-- eslint-enable vue/no-v-html -->
     <span
       v-else
       v-text="decodedTextGlyph"

+ 2 - 0
frontend/resources/vue/components/DashboardComponentDirectory.vue

@@ -4,10 +4,12 @@
     :class="component.cssClass"
     @click="navigateToDirectory"
   >
+    <!-- eslint-disable vue/no-v-html -- intentional: directory icons from config (HTML entities / markup) -->
     <span
       class="icon"
       v-html="unicodeIcon"
     />
+    <!-- eslint-enable vue/no-v-html -->
     <span class="title">{{ component.title }}</span>
   </button>
 </template>

+ 2 - 0
frontend/resources/vue/components/DashboardComponentDisplay.vue

@@ -3,7 +3,9 @@
     class="display"
     :class="component.cssClass"
   >
+    <!-- eslint-disable vue/no-v-html -- intentional: type display titles are trusted config HTML (hackable dashboards) -->
     <div v-html="component.title" />
+    <!-- eslint-enable vue/no-v-html -->
   </div>
 </template>
 

+ 21 - 0
frontend/resources/vue/utils/logsFilterStorage.js

@@ -0,0 +1,21 @@
+export const LOGS_FILTER_STORAGE_KEY = 'olivetin-logs-filter'
+
+export function loadStoredLogsFilter () {
+  try {
+    return sessionStorage.getItem(LOGS_FILTER_STORAGE_KEY) || ''
+  } catch {
+    return ''
+  }
+}
+
+export function storeLogsFilter (value) {
+  try {
+    if (value) {
+      sessionStorage.setItem(LOGS_FILTER_STORAGE_KEY, value)
+    } else {
+      sessionStorage.removeItem(LOGS_FILTER_STORAGE_KEY)
+    }
+  } catch {
+    // Ignore storage failures (private mode, quota, etc.)
+  }
+}

+ 8 - 23
frontend/resources/vue/views/ArgumentForm.vue

@@ -4,17 +4,12 @@
       <h2>
         <span class="section-title-with-icon">
           Start action:
-          <router-link
-            :to="`/action/${bindingId}`"
-            class="action-details-title-link"
-          >
-            <ActionIconGlyph
-              v-if="icon"
-              class="action-title-icon"
-              :glyph="icon"
-            />
-            {{ title }}
-          </router-link>
+          <ActionIconGlyph
+            v-if="icon"
+            class="action-title-icon"
+            :glyph="icon"
+          />
+          {{ title }}
         </span>
       </h2>
     </div>
@@ -94,10 +89,12 @@
               @change="handleChange(arg, $event)"
             />
 
+            <!-- eslint-disable vue/no-v-html -- intentional: argument description is documented as raw HTML -->
             <span
               class="argument-description"
               v-html="arg.description"
             />
+            <!-- eslint-enable vue/no-v-html -->
           </template>
         </template>
 
@@ -684,18 +681,6 @@ onUnmounted(() => {
   font-size: 1.5rem;
 }
 
-.action-details-title-link {
-  display: inline-flex;
-  align-items: center;
-  gap: 0.5rem;
-  color: var(--link-color, #0066cc);
-  text-decoration: underline;
-}
-
-.action-details-title-link:hover {
-  color: var(--link-hover-color, #004499);
-}
-
 form {
   grid-template-columns: max-content auto auto;
 }

+ 1 - 1
frontend/resources/vue/views/DiagnosticsView.vue

@@ -125,11 +125,11 @@ const browserInfoCopied = ref(false)
 
 const configIssueHeaders = computed(() => [
   { key: 'severity', label: t('diagnostics.config-issue-severity'), sortable: true, width: '7rem' },
+  { key: 'configFile', label: t('diagnostics.config-issue-config-file'), sortable: true, width: '14rem' },
   { key: 'code', label: t('diagnostics.config-issue-code'), sortable: true, width: '12rem' },
   { key: 'message', label: t('diagnostics.config-issue-message'), sortable: false },
   { key: 'actionTitle', label: t('diagnostics.config-issue-action'), sortable: true, width: '10rem' },
   { key: 'argumentName', label: t('diagnostics.config-issue-argument'), sortable: true, width: '8rem' },
-  { key: 'configFile', label: t('diagnostics.config-issue-config-file'), sortable: true, width: '14rem' },
   { key: 'source', label: t('diagnostics.config-issue-source'), sortable: false, width: '12rem' }
 ])
 

+ 2 - 2
frontend/resources/vue/views/ExecutionView.vue

@@ -58,7 +58,7 @@
     >
       <dl class="fg1">
         <dt>Duration</dt>
-        <dd><span v-html="duration" /></dd>
+        <dd>{{ duration }}</dd>
 
         <dt>Status</dt>
         <dd class="execution-dialog-status">
@@ -373,7 +373,7 @@ function updateDuration (logEntryParam) {
     } catch (e) {
 	  console.warn('Failed to calculate delta', e)
     }
-    duration.value = logEntry.value.datetimeStarted + ' &rarr; ' + logEntry.value.datetimeFinished
+    duration.value = logEntry.value.datetimeStarted + '  ' + logEntry.value.datetimeFinished
     if (delta !== '') {
 	  duration.value += ' (' + delta + ')'
     }

+ 14 - 2
frontend/resources/vue/views/LogsCalendarView.vue

@@ -5,7 +5,7 @@
   >
     <template #toolbar>
       <router-link
-        to="/logs"
+        :to="logsListLocation"
         class="button neutral"
       >
         <svg
@@ -44,6 +44,7 @@ import { useRouter } from 'vue-router'
 import { useI18n } from 'vue-i18n'
 import Calendar from 'picocrank/vue/components/Calendar.vue'
 import Section from 'picocrank/vue/components/Section.vue'
+import { loadStoredLogsFilter } from '../utils/logsFilterStorage.js'
 
 const router = useRouter()
 const { t } = useI18n()
@@ -54,6 +55,11 @@ const error = ref(null)
 const currentMonthIndex = ref(new Date().getMonth())
 const currentYear = ref(new Date().getFullYear())
 
+const logsListLocation = computed(() => {
+  const filter = loadStoredLogsFilter()
+  return filter ? { path: '/logs', query: { filter } } : '/logs'
+})
+
 // Convert logs to calendar events format
 const calendarEvents = computed(() => {
   return logs.value
@@ -147,7 +153,13 @@ function handleDayClick (date) {
   const month = String(date.getMonth() + 1).padStart(2, '0')
   const day = String(date.getDate()).padStart(2, '0')
   const dateString = `${year}-${month}-${day}`
-  router.push({ path: '/logs', query: { date: dateString } })
+  const query = { date: dateString }
+  const storedFilter = loadStoredLogsFilter()
+  if (storedFilter) {
+    query.filter = storedFilter
+  }
+
+  router.push({ path: '/logs', query })
 }
 
 function handleMonthChange (month, year) {

+ 40 - 2
frontend/resources/vue/views/LogsListView.vue

@@ -221,12 +221,24 @@ import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
 import ActionIconGlyph from '../components/ActionIconGlyph.vue'
 import LogActionTitle from '../components/LogActionTitle.vue'
 import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
+import { loadStoredLogsFilter, storeLogsFilter } from '../utils/logsFilterStorage.js'
 
 const route = useRoute()
 const router = useRouter()
 
+function readInitialFilter () {
+  // Prefer ?filter= when present (e.g. calendar keeps it while adding ?date=).
+  // Otherwise restore from sessionStorage so sidebar / breadcrumb returns keep the filter.
+  if (typeof route.query.filter === 'string' && route.query.filter !== '') {
+    storeLogsFilter(route.query.filter)
+    return route.query.filter
+  }
+
+  return loadStoredLogsFilter()
+}
+
 const logs = ref([])
-const searchText = ref('')
+const searchText = ref(readInitialFilter())
 const pageSize = ref(10)
 const currentPage = ref(1)
 const loading = ref(false)
@@ -260,11 +272,37 @@ watch(() => route.query.date, () => {
   updateDateFromRoute()
 })
 
-watch(searchText, () => {
+watch(searchText, (value) => {
   currentPage.value = 1
+  storeLogsFilter(value)
+  syncFilterToRoute(value)
   scheduleFetchLogs()
 })
 
+watch(() => route.query.filter, (filter) => {
+  const next = typeof filter === 'string' ? filter : ''
+  if (searchText.value === next) {
+    return
+  }
+  searchText.value = next
+})
+
+function syncFilterToRoute (value) {
+  const next = value || ''
+  const current = typeof route.query.filter === 'string' ? route.query.filter : ''
+  if (next === current) {
+    return
+  }
+
+  const query = { ...route.query }
+  if (next) {
+    query.filter = next
+  } else {
+    delete query.filter
+  }
+  router.replace({ path: route.path, query })
+}
+
 async function fetchLogs () {
   loading.value = true
   filterError.value = ''

+ 4 - 1
integration-tests/lib/elements.js

@@ -167,7 +167,10 @@ export async function openSidebar() {
 }
 
 export async function getNavigationLinks() {
-  const navigationLinks = await webdriver.findElements(By.css('.navigation-links li'))
+  // Exclude section headers (e.g. "System"); they are li.nav-section-header-item.
+  const navigationLinks = await webdriver.findElements(
+    By.css('.navigation-links li:not(.nav-section-header-item)')
+  )
 
   return navigationLinks
 }

+ 6 - 0
lang/combined_output.json

@@ -82,6 +82,7 @@
             "nav.diagnostics": "Diagnostik",
             "nav.entities": "Entitäten",
             "nav.logs": "Protokolle",
+            "nav.system": "System",
             "raise-issue": "Ein Problem melden auf GitHub",
             "reconnecting": "Verbinde erneut…",
             "return-to-index": "Zurück zur Startseite",
@@ -172,6 +173,7 @@
             "nav.diagnostics": "Diagnostics",
             "nav.entities": "Entities",
             "nav.logs": "Logs",
+            "nav.system": "System",
             "raise-issue": "Raise an issue on GitHub",
             "reconnecting": "Reconnecting…",
             "return-to-index": "Return to index",
@@ -262,6 +264,7 @@
             "nav.diagnostics": "Diagnósticos",
             "nav.entities": "Entidades",
             "nav.logs": "Registros",
+            "nav.system": "Sistema",
             "raise-issue": "Reportar un problema en GitHub",
             "reconnecting": "Reconectando…",
             "return-to-index": "Volver a la página principal",
@@ -352,6 +355,7 @@
             "nav.diagnostics": "Diagnostica",
             "nav.entities": "Entità",
             "nav.logs": "Registri",
+            "nav.system": "Sistema",
             "raise-issue": "Segnala un problema su GitHub",
             "reconnecting": "Riconnessione…",
             "return-to-index": "Torna alla pagina principale",
@@ -442,6 +446,7 @@
             "nav.diagnostics": "诊断",
             "nav.entities": "实体",
             "nav.logs": "日志",
+            "nav.system": "系统",
             "raise-issue": "在 GitHub 上报告问题",
             "reconnecting": "正在重新连接…",
             "return-to-index": "返回首页",
@@ -532,6 +537,7 @@
             "nav.diagnostics": "診斷",
             "nav.entities": "實體",
             "nav.logs": "日誌",
+            "nav.system": "系統",
             "raise-issue": "在 GitHub 上建立 Issue",
             "reconnecting": "重新連線中…",
             "return-to-index": "返回首頁",

+ 1 - 0
lang/de-DE.yaml

@@ -5,6 +5,7 @@ translations:
   nav.logs: Protokolle
   nav.entities: Entitäten
   nav.diagnostics: Diagnostik
+  nav.system: System
   connected: Verbunden
   disconnected: Getrennt
   reconnecting: Verbinde erneut…

+ 1 - 0
lang/en.yaml

@@ -7,6 +7,7 @@ translations:
   nav.logs: Logs
   nav.entities: Entities
   nav.diagnostics: Diagnostics
+  nav.system: System
   connected: Connected
   disconnected: Disconnected
   reconnecting: Reconnecting…

+ 1 - 0
lang/es-ES.yaml

@@ -5,6 +5,7 @@ translations:
   nav.logs: Registros
   nav.entities: Entidades
   nav.diagnostics: Diagnósticos
+  nav.system: Sistema
   connected: Conectado
   disconnected: Desconectado
   reconnecting: Reconectando…

+ 1 - 0
lang/it-IT.yaml

@@ -5,6 +5,7 @@ translations:
   nav.logs: Registri
   nav.entities: Entità
   nav.diagnostics: Diagnostica
+  nav.system: Sistema
   docs: Documentazione
   connected: Connesso
   disconnected: Disconnesso

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

@@ -5,6 +5,7 @@ translations:
   nav.logs: 日志
   nav.entities: 实体
   nav.diagnostics: 诊断
+  nav.system: 系统
   connected: 已连接
   disconnected: 已断开连接
   reconnecting: 正在重新连接…

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

@@ -7,6 +7,7 @@ translations:
   nav.logs: 日誌
   nav.entities: 實體
   nav.diagnostics: 診斷
+  nav.system: 系統
   connected: 已連線
   disconnected: 已斷線
   reconnecting: 重新連線中…

+ 6 - 0
proto/olivetin/api/v1/olivetin.proto

@@ -425,6 +425,12 @@ message InitResponse {
 	repeated string available_themes = 24; // List of available theme names
 	bool show_navigate_on_start_icons = 25;
 	int32 config_issue_count = 26;
+	repeated RootDashboard root_dashboard_entries = 27;
+}
+
+message RootDashboard {
+	string title = 1;
+	string category = 2;
 }
 
 message AdditionalLink {

+ 121 - 0
service/.golangci.yml

@@ -7,17 +7,138 @@ run:
 linters:
   default: none
   enable:
+    - bidichk
+    - bodyclose
+    - copyloopvar
+    - durationcheck
     - errcheck
+    - errorlint
     - gocritic
     - gocyclo
+    - gosec
+    - govet
     - ineffassign
     - misspell
+#    - modernize
+    - nilerr
+    - noctx
+#    - promlinter
     - staticcheck
+#    - testifylint
+    - thelper
     - unconvert
+#    - unparam
     - unused
+    - usestdlibvars
   settings:
     gocyclo:
       min-complexity: 5
+    gosec:
+      # Full gosec rule set (G101–G6xx), including Slowloris checks G112/G114.
+      enable-all-rules: true
+    govet:
+      enable-all: true
   exclusions:
     paths:
       - gen
+    rules:
+      # Noise / fixtures in tests and local tooling.
+      - path: _test\.go
+        linters:
+          - gosec
+      - path: _test\.go
+        text: "fieldalignment:"
+        linters:
+          - govet
+      - path: scripts/
+        linters:
+          - gosec
+      # Local config-tool CLI: operator-supplied path and 0644 config backups.
+      - path: cmd/config-tool/main\.go
+        text: "G304:"
+        linters:
+          - gosec
+      - path: cmd/config-tool/main\.go
+        text: "G306:"
+        linters:
+          - gosec
+      - path: cmd/config-tool/main\.go
+        text: "G703:"
+        linters:
+          - gosec
+
+      # OliveTin's purpose is controlled command execution from config.
+      - path: internal/executor/
+        text: "G204:"
+        linters:
+          - gosec
+
+      # Operator-configured filesystem paths (entity files, touch/write helpers, persisted logs).
+      - path: internal/entities/
+        text: "G304:"
+        linters:
+          - gosec
+      - path: internal/filehelper/
+        text: "G304:"
+        linters:
+          - gosec
+      - path: internal/configcheck/
+        text: "G304:"
+        linters:
+          - gosec
+      - path: internal/executor/
+        text: "G304:"
+        linters:
+          - gosec
+      - path: internal/auth/otjwt/
+        text: "G304:"
+        linters:
+          - gosec
+      - path: internal/httpservers/
+        text: "G304:"
+        linters:
+          - gosec
+
+      # Legacy GitHub webhook HMAC-SHA1 is still a supported authType.
+      - path: internal/webhooks/auth\.go
+        text: "G505:"
+        linters:
+          - gosec
+
+      # InsecureSkipVerify is an explicit OAuth2 provider config option.
+      - path: internal/auth/otoauth2/
+        text: "G402:"
+        linters:
+          - gosec
+
+      # Secure is set dynamically from TLS / ForceSecureCookies; gosec wants a literal true.
+      - path: internal/api/api\.go
+        text: "G124:"
+        linters:
+          - gosec
+      - path: internal/auth/otoauth2/
+        text: "G124:"
+        linters:
+          - gosec
+
+      # Protobuf / process exit codes and collection sizes mapped into int32 fields.
+      - path: internal/api/apiActions\.go
+        text: "G115:"
+        linters:
+          - gosec
+      - path: internal/api/api_entities_list\.go
+        text: "G115:"
+        linters:
+          - gosec
+      - path: internal/api/api_queue\.go
+        text: "G115:"
+        linters:
+          - gosec
+      - path: internal/api/config_issues\.go
+        text: "G115:"
+        linters:
+          - gosec
+      - path: internal/executor/executor\.go
+        text: "G115:"
+        linters:
+          - gosec

+ 11 - 10
service/cmd/config-tool/main.go

@@ -3,6 +3,7 @@ package main
 import (
 	"flag"
 	"fmt"
+	"maps"
 	"os"
 	"path/filepath"
 	"strconv"
@@ -98,18 +99,18 @@ func userDisplayName(username string, index int) string {
 	return username
 }
 
-func copyUserMapWithPassword(userMap map[string]interface{}, hashedPassword string) map[string]interface{} {
-	newUserMap := make(map[string]interface{}, len(userMap)+1)
-	for key, value := range userMap {
-		newUserMap[key] = value
-	}
+func copyUserMapWithPassword(userMap map[string]any, hashedPassword string) map[string]any {
+	newUserMap := make(map[string]any, len(userMap)+1)
+
+	maps.Copy(newUserMap, userMap)
+
 	newUserMap["password"] = hashedPassword
 
 	return newUserMap
 }
 
-func resetPasswordInUserMap(userValue interface{}, index int, hashedPassword string) interface{} {
-	userMap, ok := userValue.(map[string]interface{})
+func resetPasswordInUserMap(userValue any, index int, hashedPassword string) any {
+	userMap, ok := userValue.(map[string]any)
 	if !ok {
 		log.Warnf("User entry at index %d is not a map, skipping", index)
 		return userValue
@@ -122,8 +123,8 @@ func resetPasswordInUserMap(userValue interface{}, index int, hashedPassword str
 	return copyUserMapWithPassword(userMap, hashedPassword)
 }
 
-func resetPasswordsFromSlice(k *koanf.Koanf, usersSliceTyped []interface{}, hashedPassword string) {
-	newUsersSlice := make([]interface{}, len(usersSliceTyped))
+func resetPasswordsFromSlice(k *koanf.Koanf, usersSliceTyped []any, hashedPassword string) {
+	newUsersSlice := make([]any, len(usersSliceTyped))
 	for index, userValue := range usersSliceTyped {
 		newUsersSlice[index] = resetPasswordInUserMap(userValue, index, hashedPassword)
 	}
@@ -155,7 +156,7 @@ func hasLocalUsers(cfg *config.Config) bool {
 }
 
 func applyPasswordResets(k *koanf.Koanf, cfg *config.Config, hashedPassword string) {
-	usersSliceTyped, ok := k.Get("authLocalUsers.users").([]interface{})
+	usersSliceTyped, ok := k.Get("authLocalUsers.users").([]any)
 	if ok && len(usersSliceTyped) > 0 {
 		resetPasswordsFromSlice(k, usersSliceTyped, hashedPassword)
 		return

+ 190 - 123
service/gen/olivetin/api/v1/olivetin.pb.go

@@ -4095,6 +4095,7 @@ type InitResponse struct {
 	AvailableThemes           []string               `protobuf:"bytes,24,rep,name=available_themes,json=availableThemes,proto3" json:"available_themes,omitempty"` // List of available theme names
 	ShowNavigateOnStartIcons  bool                   `protobuf:"varint,25,opt,name=show_navigate_on_start_icons,json=showNavigateOnStartIcons,proto3" json:"show_navigate_on_start_icons,omitempty"`
 	ConfigIssueCount          int32                  `protobuf:"varint,26,opt,name=config_issue_count,json=configIssueCount,proto3" json:"config_issue_count,omitempty"`
+	RootDashboardEntries      []*RootDashboard       `protobuf:"bytes,27,rep,name=root_dashboard_entries,json=rootDashboardEntries,proto3" json:"root_dashboard_entries,omitempty"`
 	unknownFields             protoimpl.UnknownFields
 	sizeCache                 protoimpl.SizeCache
 }
@@ -4311,6 +4312,65 @@ func (x *InitResponse) GetConfigIssueCount() int32 {
 	return 0
 }
 
+func (x *InitResponse) GetRootDashboardEntries() []*RootDashboard {
+	if x != nil {
+		return x.RootDashboardEntries
+	}
+	return nil
+}
+
+type RootDashboard struct {
+	state         protoimpl.MessageState `protogen:"open.v1"`
+	Title         string                 `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
+	Category      string                 `protobuf:"bytes,2,opt,name=category,proto3" json:"category,omitempty"`
+	unknownFields protoimpl.UnknownFields
+	sizeCache     protoimpl.SizeCache
+}
+
+func (x *RootDashboard) Reset() {
+	*x = RootDashboard{}
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
+	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+	ms.StoreMessageInfo(mi)
+}
+
+func (x *RootDashboard) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*RootDashboard) ProtoMessage() {}
+
+func (x *RootDashboard) ProtoReflect() protoreflect.Message {
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
+	if x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use RootDashboard.ProtoReflect.Descriptor instead.
+func (*RootDashboard) Descriptor() ([]byte, []int) {
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{69}
+}
+
+func (x *RootDashboard) GetTitle() string {
+	if x != nil {
+		return x.Title
+	}
+	return ""
+}
+
+func (x *RootDashboard) GetCategory() string {
+	if x != nil {
+		return x.Category
+	}
+	return ""
+}
+
 type AdditionalLink struct {
 	state         protoimpl.MessageState `protogen:"open.v1"`
 	Title         string                 `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
@@ -4321,7 +4381,7 @@ type AdditionalLink struct {
 
 func (x *AdditionalLink) Reset() {
 	*x = AdditionalLink{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4333,7 +4393,7 @@ func (x *AdditionalLink) String() string {
 func (*AdditionalLink) ProtoMessage() {}
 
 func (x *AdditionalLink) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4346,7 +4406,7 @@ func (x *AdditionalLink) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use AdditionalLink.ProtoReflect.Descriptor instead.
 func (*AdditionalLink) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{69}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{70}
 }
 
 func (x *AdditionalLink) GetTitle() string {
@@ -4374,7 +4434,7 @@ type OAuth2Provider struct {
 
 func (x *OAuth2Provider) Reset() {
 	*x = OAuth2Provider{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4386,7 +4446,7 @@ func (x *OAuth2Provider) String() string {
 func (*OAuth2Provider) ProtoMessage() {}
 
 func (x *OAuth2Provider) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4399,7 +4459,7 @@ func (x *OAuth2Provider) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use OAuth2Provider.ProtoReflect.Descriptor instead.
 func (*OAuth2Provider) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{70}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{71}
 }
 
 func (x *OAuth2Provider) GetTitle() string {
@@ -4432,7 +4492,7 @@ type GetActionBindingRequest struct {
 
 func (x *GetActionBindingRequest) Reset() {
 	*x = GetActionBindingRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4444,7 +4504,7 @@ func (x *GetActionBindingRequest) String() string {
 func (*GetActionBindingRequest) ProtoMessage() {}
 
 func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4457,7 +4517,7 @@ func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetActionBindingRequest.ProtoReflect.Descriptor instead.
 func (*GetActionBindingRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{71}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{72}
 }
 
 func (x *GetActionBindingRequest) GetBindingId() string {
@@ -4477,7 +4537,7 @@ type GetActionBindingResponse struct {
 
 func (x *GetActionBindingResponse) Reset() {
 	*x = GetActionBindingResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4489,7 +4549,7 @@ func (x *GetActionBindingResponse) String() string {
 func (*GetActionBindingResponse) ProtoMessage() {}
 
 func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4502,7 +4562,7 @@ func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetActionBindingResponse.ProtoReflect.Descriptor instead.
 func (*GetActionBindingResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{72}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{73}
 }
 
 func (x *GetActionBindingResponse) GetAction() *Action {
@@ -4531,7 +4591,7 @@ type GetEntitiesRequest struct {
 
 func (x *GetEntitiesRequest) Reset() {
 	*x = GetEntitiesRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4543,7 +4603,7 @@ func (x *GetEntitiesRequest) String() string {
 func (*GetEntitiesRequest) ProtoMessage() {}
 
 func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4556,7 +4616,7 @@ func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetEntitiesRequest.ProtoReflect.Descriptor instead.
 func (*GetEntitiesRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{73}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{74}
 }
 
 func (x *GetEntitiesRequest) GetEntityType() string {
@@ -4596,7 +4656,7 @@ type GetEntitiesResponse struct {
 
 func (x *GetEntitiesResponse) Reset() {
 	*x = GetEntitiesResponse{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4608,7 +4668,7 @@ func (x *GetEntitiesResponse) String() string {
 func (*GetEntitiesResponse) ProtoMessage() {}
 
 func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4621,7 +4681,7 @@ func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetEntitiesResponse.ProtoReflect.Descriptor instead.
 func (*GetEntitiesResponse) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{74}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{75}
 }
 
 func (x *GetEntitiesResponse) GetEntityDefinitions() []*EntityDefinition {
@@ -4645,7 +4705,7 @@ type EntityDefinition struct {
 
 func (x *EntityDefinition) Reset() {
 	*x = EntityDefinition{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4657,7 +4717,7 @@ func (x *EntityDefinition) String() string {
 func (*EntityDefinition) ProtoMessage() {}
 
 func (x *EntityDefinition) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4670,7 +4730,7 @@ func (x *EntityDefinition) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EntityDefinition.ProtoReflect.Descriptor instead.
 func (*EntityDefinition) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{75}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{76}
 }
 
 func (x *EntityDefinition) GetTitle() string {
@@ -4725,7 +4785,7 @@ type EntityProperty struct {
 
 func (x *EntityProperty) Reset() {
 	*x = EntityProperty{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4737,7 +4797,7 @@ func (x *EntityProperty) String() string {
 func (*EntityProperty) ProtoMessage() {}
 
 func (x *EntityProperty) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4750,7 +4810,7 @@ func (x *EntityProperty) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use EntityProperty.ProtoReflect.Descriptor instead.
 func (*EntityProperty) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{76}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{77}
 }
 
 func (x *EntityProperty) GetName() string {
@@ -4777,7 +4837,7 @@ type GetEntityRequest struct {
 
 func (x *GetEntityRequest) Reset() {
 	*x = GetEntityRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[78]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4789,7 +4849,7 @@ func (x *GetEntityRequest) String() string {
 func (*GetEntityRequest) ProtoMessage() {}
 
 func (x *GetEntityRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[78]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4802,7 +4862,7 @@ func (x *GetEntityRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use GetEntityRequest.ProtoReflect.Descriptor instead.
 func (*GetEntityRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{77}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{78}
 }
 
 func (x *GetEntityRequest) GetUniqueKey() string {
@@ -4828,7 +4888,7 @@ type RestartActionRequest struct {
 
 func (x *RestartActionRequest) Reset() {
 	*x = RestartActionRequest{}
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[78]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[79]
 	ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 	ms.StoreMessageInfo(mi)
 }
@@ -4840,7 +4900,7 @@ func (x *RestartActionRequest) String() string {
 func (*RestartActionRequest) ProtoMessage() {}
 
 func (x *RestartActionRequest) ProtoReflect() protoreflect.Message {
-	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[78]
+	mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[79]
 	if x != nil {
 		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
 		if ms.LoadMessageInfo() == nil {
@@ -4853,7 +4913,7 @@ func (x *RestartActionRequest) ProtoReflect() protoreflect.Message {
 
 // Deprecated: Use RestartActionRequest.ProtoReflect.Descriptor instead.
 func (*RestartActionRequest) Descriptor() ([]byte, []int) {
-	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{78}
+	return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{79}
 }
 
 func (x *RestartActionRequest) GetExecutionTrackingId() string {
@@ -5175,7 +5235,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\vSshFoundKey\x18\x01 \x01(\tR\vSshFoundKey\x12&\n" +
 	"\x0eSshFoundConfig\x18\x02 \x01(\tR\x0eSshFoundConfig\x12A\n" +
 	"\rconfig_issues\x18\x03 \x03(\v2\x1c.olivetin.api.v1.ConfigIssueR\fconfigIssues\"\r\n" +
-	"\vInitRequest\"\xbb\t\n" +
+	"\vInitRequest\"\x91\n" +
+	"\n" +
 	"\fInitResponse\x12\x1e\n" +
 	"\n" +
 	"showFooter\x18\x01 \x01(\bR\n" +
@@ -5206,7 +5267,11 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
 	"\x0elogin_required\x18\x17 \x01(\bR\rloginRequired\x12)\n" +
 	"\x10available_themes\x18\x18 \x03(\tR\x0favailableThemes\x12>\n" +
 	"\x1cshow_navigate_on_start_icons\x18\x19 \x01(\bR\x18showNavigateOnStartIcons\x12,\n" +
-	"\x12config_issue_count\x18\x1a \x01(\x05R\x10configIssueCount\"8\n" +
+	"\x12config_issue_count\x18\x1a \x01(\x05R\x10configIssueCount\x12T\n" +
+	"\x16root_dashboard_entries\x18\x1b \x03(\v2\x1e.olivetin.api.v1.RootDashboardR\x14rootDashboardEntries\"A\n" +
+	"\rRootDashboard\x12\x14\n" +
+	"\x05title\x18\x01 \x01(\tR\x05title\x12\x1a\n" +
+	"\bcategory\x18\x02 \x01(\tR\bcategory\"8\n" +
 	"\x0eAdditionalLink\x12\x14\n" +
 	"\x05title\x18\x01 \x01(\tR\x05title\x12\x10\n" +
 	"\x03url\x18\x02 \x01(\tR\x03url\"L\n" +
@@ -5287,7 +5352,7 @@ func file_olivetin_api_v1_olivetin_proto_rawDescGZIP() []byte {
 	return file_olivetin_api_v1_olivetin_proto_rawDescData
 }
 
-var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 86)
+var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 87)
 var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
 	(*Action)(nil),                          // 0: olivetin.api.v1.Action
 	(*ActionGroupMembership)(nil),           // 1: olivetin.api.v1.ActionGroupMembership
@@ -5358,35 +5423,36 @@ var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
 	(*GetDiagnosticsResponse)(nil),          // 66: olivetin.api.v1.GetDiagnosticsResponse
 	(*InitRequest)(nil),                     // 67: olivetin.api.v1.InitRequest
 	(*InitResponse)(nil),                    // 68: olivetin.api.v1.InitResponse
-	(*AdditionalLink)(nil),                  // 69: olivetin.api.v1.AdditionalLink
-	(*OAuth2Provider)(nil),                  // 70: olivetin.api.v1.OAuth2Provider
-	(*GetActionBindingRequest)(nil),         // 71: olivetin.api.v1.GetActionBindingRequest
-	(*GetActionBindingResponse)(nil),        // 72: olivetin.api.v1.GetActionBindingResponse
-	(*GetEntitiesRequest)(nil),              // 73: olivetin.api.v1.GetEntitiesRequest
-	(*GetEntitiesResponse)(nil),             // 74: olivetin.api.v1.GetEntitiesResponse
-	(*EntityDefinition)(nil),                // 75: olivetin.api.v1.EntityDefinition
-	(*EntityProperty)(nil),                  // 76: olivetin.api.v1.EntityProperty
-	(*GetEntityRequest)(nil),                // 77: olivetin.api.v1.GetEntityRequest
-	(*RestartActionRequest)(nil),            // 78: olivetin.api.v1.RestartActionRequest
-	nil,                                     // 79: olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
-	nil,                                     // 80: olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
-	nil,                                     // 81: olivetin.api.v1.ActionArgument.SuggestionsEntry
-	nil,                                     // 82: olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
-	nil,                                     // 83: olivetin.api.v1.Entity.FieldsEntry
-	nil,                                     // 84: olivetin.api.v1.DumpVarsResponse.ContentsEntry
-	nil,                                     // 85: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
+	(*RootDashboard)(nil),                   // 69: olivetin.api.v1.RootDashboard
+	(*AdditionalLink)(nil),                  // 70: olivetin.api.v1.AdditionalLink
+	(*OAuth2Provider)(nil),                  // 71: olivetin.api.v1.OAuth2Provider
+	(*GetActionBindingRequest)(nil),         // 72: olivetin.api.v1.GetActionBindingRequest
+	(*GetActionBindingResponse)(nil),        // 73: olivetin.api.v1.GetActionBindingResponse
+	(*GetEntitiesRequest)(nil),              // 74: olivetin.api.v1.GetEntitiesRequest
+	(*GetEntitiesResponse)(nil),             // 75: olivetin.api.v1.GetEntitiesResponse
+	(*EntityDefinition)(nil),                // 76: olivetin.api.v1.EntityDefinition
+	(*EntityProperty)(nil),                  // 77: olivetin.api.v1.EntityProperty
+	(*GetEntityRequest)(nil),                // 78: olivetin.api.v1.GetEntityRequest
+	(*RestartActionRequest)(nil),            // 79: olivetin.api.v1.RestartActionRequest
+	nil,                                     // 80: olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
+	nil,                                     // 81: olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
+	nil,                                     // 82: olivetin.api.v1.ActionArgument.SuggestionsEntry
+	nil,                                     // 83: olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
+	nil,                                     // 84: olivetin.api.v1.Entity.FieldsEntry
+	nil,                                     // 85: olivetin.api.v1.DumpVarsResponse.ContentsEntry
+	nil,                                     // 86: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
 }
 var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
 	3,  // 0: olivetin.api.v1.Action.arguments:type_name -> olivetin.api.v1.ActionArgument
 	2,  // 1: olivetin.api.v1.Action.exec_on_webhooks:type_name -> olivetin.api.v1.ActionWebhookExecHint
 	1,  // 2: olivetin.api.v1.Action.groups:type_name -> olivetin.api.v1.ActionGroupMembership
-	79, // 3: olivetin.api.v1.ActionWebhookExecHint.match_headers:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
-	80, // 4: olivetin.api.v1.ActionWebhookExecHint.match_query:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
+	80, // 3: olivetin.api.v1.ActionWebhookExecHint.match_headers:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
+	81, // 4: olivetin.api.v1.ActionWebhookExecHint.match_query:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
 	4,  // 5: olivetin.api.v1.ActionArgument.choices:type_name -> olivetin.api.v1.ActionArgumentChoice
-	81, // 6: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry
+	82, // 6: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry
 	0,  // 7: olivetin.api.v1.EntityRelatedAction.action:type_name -> olivetin.api.v1.Action
-	82, // 8: olivetin.api.v1.EntityRelatedAction.prefilled_arguments:type_name -> olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
-	83, // 9: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry
+	83, // 8: olivetin.api.v1.EntityRelatedAction.prefilled_arguments:type_name -> olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
+	84, // 9: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry
 	5,  // 10: olivetin.api.v1.Entity.related_actions:type_name -> olivetin.api.v1.EntityRelatedAction
 	10, // 11: olivetin.api.v1.GetDashboardResponse.dashboard:type_name -> olivetin.api.v1.Dashboard
 	11, // 12: olivetin.api.v1.Dashboard.contents:type_name -> olivetin.api.v1.DashboardComponent
@@ -5404,8 +5470,8 @@ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
 	28, // 24: olivetin.api.v1.GetExecutionQueueResponse.groups:type_name -> olivetin.api.v1.ExecutionQueueGroup
 	22, // 25: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
 	35, // 26: olivetin.api.v1.ExecutionStatusResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
-	84, // 27: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
-	85, // 28: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
+	85, // 27: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
+	86, // 28: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
 	51, // 29: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged
 	52, // 30: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged
 	54, // 31: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished
@@ -5415,72 +5481,73 @@ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
 	22, // 35: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry
 	22, // 36: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry
 	65, // 37: olivetin.api.v1.GetDiagnosticsResponse.config_issues:type_name -> olivetin.api.v1.ConfigIssue
-	70, // 38: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
-	69, // 39: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
+	71, // 38: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
+	70, // 39: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
 	8,  // 40: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy
-	0,  // 41: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
-	35, // 42: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
-	75, // 43: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
-	6,  // 44: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
-	76, // 45: olivetin.api.v1.EntityDefinition.properties:type_name -> olivetin.api.v1.EntityProperty
-	43, // 46: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
-	9,  // 47: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
-	12, // 48: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
-	15, // 49: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
-	17, // 50: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
-	19, // 51: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
-	78, // 52: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
-	56, // 53: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
-	34, // 54: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
-	21, // 55: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
-	24, // 56: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
-	26, // 57: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest
-	30, // 58: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
-	37, // 59: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
-	39, // 60: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:input_type -> olivetin.api.v1.ServerDiagnosticsRequest
-	41, // 61: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
-	44, // 62: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
-	46, // 63: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
-	58, // 64: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
-	60, // 65: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
-	62, // 66: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
-	48, // 67: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
-	64, // 68: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
-	67, // 69: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
-	71, // 70: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
-	73, // 71: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
-	77, // 72: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
-	7,  // 73: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
-	14, // 74: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
-	16, // 75: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
-	18, // 76: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
-	20, // 77: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
-	14, // 78: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
-	57, // 79: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
-	36, // 80: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
-	23, // 81: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
-	25, // 82: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
-	29, // 83: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse
-	31, // 84: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
-	38, // 85: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
-	40, // 86: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:output_type -> olivetin.api.v1.ServerDiagnosticsResponse
-	42, // 87: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
-	45, // 88: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
-	47, // 89: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
-	59, // 90: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
-	61, // 91: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
-	63, // 92: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
-	49, // 93: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
-	66, // 94: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
-	68, // 95: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
-	72, // 96: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
-	74, // 97: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
-	6,  // 98: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
-	73, // [73:99] is the sub-list for method output_type
-	47, // [47:73] is the sub-list for method input_type
-	47, // [47:47] is the sub-list for extension type_name
-	47, // [47:47] is the sub-list for extension extendee
-	0,  // [0:47] is the sub-list for field type_name
+	69, // 41: olivetin.api.v1.InitResponse.root_dashboard_entries:type_name -> olivetin.api.v1.RootDashboard
+	0,  // 42: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
+	35, // 43: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
+	76, // 44: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
+	6,  // 45: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
+	77, // 46: olivetin.api.v1.EntityDefinition.properties:type_name -> olivetin.api.v1.EntityProperty
+	43, // 47: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
+	9,  // 48: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
+	12, // 49: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
+	15, // 50: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
+	17, // 51: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
+	19, // 52: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
+	79, // 53: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
+	56, // 54: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
+	34, // 55: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
+	21, // 56: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
+	24, // 57: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
+	26, // 58: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest
+	30, // 59: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
+	37, // 60: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
+	39, // 61: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:input_type -> olivetin.api.v1.ServerDiagnosticsRequest
+	41, // 62: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
+	44, // 63: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
+	46, // 64: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
+	58, // 65: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
+	60, // 66: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
+	62, // 67: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
+	48, // 68: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
+	64, // 69: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
+	67, // 70: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
+	72, // 71: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
+	74, // 72: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
+	78, // 73: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
+	7,  // 74: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
+	14, // 75: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
+	16, // 76: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
+	18, // 77: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
+	20, // 78: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
+	14, // 79: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
+	57, // 80: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
+	36, // 81: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
+	23, // 82: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
+	25, // 83: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
+	29, // 84: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse
+	31, // 85: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
+	38, // 86: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
+	40, // 87: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:output_type -> olivetin.api.v1.ServerDiagnosticsResponse
+	42, // 88: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
+	45, // 89: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
+	47, // 90: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
+	59, // 91: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
+	61, // 92: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
+	63, // 93: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
+	49, // 94: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
+	66, // 95: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
+	68, // 96: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
+	73, // 97: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
+	75, // 98: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
+	6,  // 99: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
+	74, // [74:100] is the sub-list for method output_type
+	48, // [48:74] is the sub-list for method input_type
+	48, // [48:48] is the sub-list for extension type_name
+	48, // [48:48] is the sub-list for extension extendee
+	0,  // [0:48] is the sub-list for field type_name
 }
 
 func init() { file_olivetin_api_v1_olivetin_proto_init() }
@@ -5502,7 +5569,7 @@ func file_olivetin_api_v1_olivetin_proto_init() {
 			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
 			RawDescriptor: unsafe.Slice(unsafe.StringData(file_olivetin_api_v1_olivetin_proto_rawDesc), len(file_olivetin_api_v1_olivetin_proto_rawDesc)),
 			NumEnums:      0,
-			NumMessages:   86,
+			NumMessages:   87,
 			NumExtensions: 0,
 			NumServices:   1,
 		},

+ 1 - 1
service/go.mod

@@ -28,7 +28,6 @@ require (
 	github.com/sirupsen/logrus v1.9.4
 	github.com/stretchr/testify v1.11.1
 	go.akshayshah.org/connectproto v0.6.0
-	golang.org/x/exp v0.0.0-20260718201538-764159d718ef
 	golang.org/x/oauth2 v0.36.0
 	golang.org/x/sys v0.47.0
 	google.golang.org/protobuf v1.36.11
@@ -290,6 +289,7 @@ require (
 	go.uber.org/zap v1.28.0 // indirect
 	go.yaml.in/yaml/v3 v3.0.4 // indirect
 	golang.org/x/crypto v0.54.0 // indirect
+	golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect
 	golang.org/x/exp/typeparams v0.0.0-20260718201538-764159d718ef // indirect
 	golang.org/x/mod v0.38.0 // indirect
 	golang.org/x/net v0.57.0 // indirect

+ 1 - 1
service/internal/acl/acl.go

@@ -5,7 +5,7 @@ import (
 	config "github.com/OliveTin/OliveTin/internal/config"
 	log "github.com/sirupsen/logrus"
 
-	"golang.org/x/exp/slices"
+	"slices"
 )
 
 type PermissionBits int

+ 2 - 2
service/internal/acl/acl_test.go

@@ -9,10 +9,10 @@ import (
 func Test_hasGroupsMatch(t *testing.T) {
 	tests := []struct {
 		name               string
-		aclMatchUsergroups []string
 		usergroupLine      string
-		matches            bool
 		sep                string
+		aclMatchUsergroups []string
+		matches            bool
 	}{
 		{
 			name:               "No groups match",

+ 62 - 21
service/internal/api/api.go

@@ -43,6 +43,10 @@ type oliveTinAPI struct {
 	streamingClientsMutex sync.RWMutex
 }
 
+const maxEventStreamClients = 16
+
+var errEventStreamClientLimit = errors.New("too many concurrent event stream clients")
+
 // This is used to avoid race conditions when iterating over the connectedClients map.
 // and holds the lock for as minimal time as possible to avoid blocking the API for too long.
 func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
@@ -58,9 +62,9 @@ func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
 type streamingClient struct {
 	channel           chan *apiv1.EventStreamResponse
 	AuthenticatedUser *authpublic.AuthenticatedUser
-	heartbeatStopOnce sync.Once
 	heartbeatStop     chan struct{}
 	heartbeatDone     chan struct{}
+	heartbeatStopOnce sync.Once
 }
 
 func (c *streamingClient) stopHeartbeat() {
@@ -306,7 +310,8 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request
 	user := auth.UserFromApiCall(ctx, req, api.cfg)
 	args := startActionArgumentsFromProto(req.Msg.Arguments)
 	justification := resolveStartJustification(binding.Action, binding, req.Msg.Justification, args)
-	if err := validateJustificationRequired(binding.Action, justification, user); err != nil {
+
+	if err = validateJustificationRequired(binding.Action, justification, user); err != nil {
 		return nil, connectInvalidJustification(err)
 	}
 
@@ -801,13 +806,13 @@ func paginate(total int64, size int64, start int64) pageInfo {
 	if start < 0 {
 		start = 0
 	}
+
 	if start >= total {
 		return pageInfo{total: total, size: size, start: start, end: start, empty: true}
 	}
-	end := start + size
-	if end > total {
-		end = total
-	}
+
+	end := min(start+size, total)
+
 	return pageInfo{total: total, size: size, start: start, end: end, empty: false}
 }
 
@@ -1028,14 +1033,14 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
 		heartbeatDone:     make(chan struct{}),
 	}
 
+	if err := api.registerStreamingClient(client); err != nil {
+		return connect.NewError(connect.CodeResourceExhausted, err)
+	}
+
 	log.WithFields(log.Fields{
 		"authenticatedUser": user.Username,
 	}).Debugf("EventStream: client connected")
 
-	api.streamingClientsMutex.Lock()
-	api.streamingClients[client] = struct{}{}
-	api.streamingClientsMutex.Unlock()
-
 	go api.sendEventStreamHeartbeats(client)
 
 	// loop over client channel and send events to connectedClient
@@ -1054,6 +1059,21 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
 	return nil
 }
 
+func (api *oliveTinAPI) registerStreamingClient(client *streamingClient) error {
+	api.streamingClientsMutex.Lock()
+	defer api.streamingClientsMutex.Unlock()
+
+	if len(api.streamingClients) >= maxEventStreamClients {
+		log.WithFields(log.Fields{
+			"limit": maxEventStreamClients,
+		}).Warn("EventStream: rejecting client; concurrent client limit reached")
+		return errEventStreamClientLimit
+	}
+
+	api.streamingClients[client] = struct{}{}
+	return nil
+}
+
 func (api *oliveTinAPI) sendEventStreamHeartbeats(client *streamingClient) {
 	defer close(client.heartbeatDone)
 
@@ -1217,6 +1237,9 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
 		currentVersion = installationinfo.Build.Version
 		availableVersion = installationinfo.Runtime.AvailableVersion
 	}
+
+	rootDashboardEntries := api.buildRootDashboardEntries(user, api.cfg.Dashboards)
+
 	res := &apiv1.InitResponse{
 		ShowFooter:                api.cfg.ShowFooter,
 		ShowNavigation:            api.cfg.ShowNavigation,
@@ -1232,7 +1255,8 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
 		OAuth2Providers:           buildPublicOAuth2ProvidersList(api.cfg),
 		AdditionalLinks:           buildAdditionalLinks(api.cfg.AdditionalNavigationLinks),
 		StyleMods:                 api.cfg.StyleMods,
-		RootDashboards:            api.buildRootDashboards(user, api.cfg.Dashboards),
+		RootDashboards:            rootDashboardTitles(rootDashboardEntries),
+		RootDashboardEntries:      rootDashboardEntries,
 		AuthenticatedUser:         user.Username,
 		AuthenticatedUserProvider: user.Provider,
 		EffectivePolicy:           buildEffectivePolicy(user.EffectivePolicy),
@@ -1300,30 +1324,47 @@ func getValidThemeName(themesDir string, entry os.DirEntry) string {
 }
 
 func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string {
-	var rootDashboards []string
+	return rootDashboardTitles(api.buildRootDashboardEntries(user, dashboards))
+}
+
+func rootDashboardTitles(entries []*apiv1.RootDashboard) []string {
+	titles := make([]string, 0, len(entries))
+
+	for _, entry := range entries {
+		titles = append(titles, entry.Title)
+	}
+
+	return titles
+}
+
+func (api *oliveTinAPI) buildRootDashboardEntries(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []*apiv1.RootDashboard {
+	var entries []*apiv1.RootDashboard
 	dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "")
 
-	api.addDefaultDashboardIfNeeded(&rootDashboards, dashboardRenderRequest)
-	api.addCustomDashboards(&rootDashboards, dashboards, dashboardRenderRequest)
+	api.addDefaultDashboardEntryIfNeeded(&entries, dashboardRenderRequest)
+	api.addCustomDashboardEntries(&entries, dashboards, dashboardRenderRequest)
 
-	return rootDashboards
+	return entries
 }
 
-func (api *oliveTinAPI) addDefaultDashboardIfNeeded(rootDashboards *[]string, rr *DashboardRenderRequest) {
+func (api *oliveTinAPI) addDefaultDashboardEntryIfNeeded(entries *[]*apiv1.RootDashboard, rr *DashboardRenderRequest) {
 	defaultDashboard := buildDefaultDashboard(rr)
 	if defaultDashboard != nil && len(defaultDashboard.Contents) > 0 {
 		log.Tracef("defaultDashboard: %+v", defaultDashboard.Contents)
-		*rootDashboards = append(*rootDashboards, "Actions")
+		*entries = append(*entries, &apiv1.RootDashboard{Title: "Actions"})
 	}
 }
 
-func (api *oliveTinAPI) addCustomDashboards(rootDashboards *[]string, dashboards []*config.DashboardComponent, rr *DashboardRenderRequest) {
+func (api *oliveTinAPI) addCustomDashboardEntries(entries *[]*apiv1.RootDashboard, dashboards []*config.DashboardComponent, rr *DashboardRenderRequest) {
 	for _, dashboard := range dashboards {
 		// We have to build the dashboard response instead of just looping over config.dashboards,
 		// because we need to check if the user has access to the dashboard
-		db := renderDashboard(rr, dashboard.Title)
-		if db != nil {
-			*rootDashboards = append(*rootDashboards, dashboard.Title)
+		renderedDashboard := renderDashboard(rr, dashboard.Title)
+		if renderedDashboard != nil {
+			*entries = append(*entries, &apiv1.RootDashboard{
+				Title:    dashboard.Title,
+				Category: dashboard.Category,
+			})
 		}
 	}
 }

+ 1 - 1
service/internal/api/apiActions.go

@@ -25,9 +25,9 @@ type DashboardRenderRequest struct {
 	AuthenticatedUser   *authpublic.AuthenticatedUser
 	cfg                 *config.Config
 	ex                  *executor.Executor
+	activeBindingStates map[string]bindingActiveState
 	EntityType          string
 	EntityKey           string
-	activeBindingStates map[string]bindingActiveState
 }
 
 func activeBindingID(entry *executor.InternalLogEntry) string {

+ 3 - 3
service/internal/api/api_log_arguments.go

@@ -2,6 +2,7 @@ package api
 
 import (
 	"fmt"
+	"maps"
 	"sort"
 	"strings"
 
@@ -35,9 +36,8 @@ func logEntryArgumentsToProto(args map[string]string) []*apiv1.StartActionArgume
 
 func copyStringMap(source map[string]string) map[string]string {
 	copied := make(map[string]string, len(source))
-	for key, value := range source {
-		copied[key] = value
-	}
+
+	maps.Copy(copied, source)
 
 	return copied
 }

+ 2 - 2
service/internal/api/api_log_arguments_test.go

@@ -75,7 +75,7 @@ func waitForLogJustification(t *testing.T, ex *executor.Executor, trackingID, ex
 func TestExecutionStatusIncludesStoredArguments(t *testing.T) {
 	cfg := config.DefaultConfig()
 	cfg.Actions = []*config.Action{
-		argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
+		argumentAction("Ping host with stored args", "echo {{ host }}", []config.ActionArgument{
 			{Name: "host", Type: "ascii_identifier"},
 		}),
 	}
@@ -236,7 +236,7 @@ func TestRestartActionRejectsIncompleteStoredArguments(t *testing.T) {
 func TestRestartActionRejectsMissingRequiredStoredArguments(t *testing.T) {
 	cfg := config.DefaultConfig()
 	cfg.Actions = []*config.Action{
-		argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
+		argumentAction("Ping host - reject missing required stored arg", "echo {{ host }}", []config.ActionArgument{
 			{Name: "host", Type: "ascii_identifier"},
 		}),
 	}

+ 67 - 14
service/internal/api/api_test.go

@@ -29,6 +29,8 @@ func getNewTestServerAndClient(injectedConfig *config.Config) (*httptest.Server,
 }
 
 func getNewTestServerAndClientWithExecutor(injectedConfig *config.Config, ex *executor.Executor) (*httptest.Server, apiv1connect.OliveTinApiServiceClient) {
+	ex.Cfg = injectedConfig
+
 	apiPath, apiHandler := GetNewHandler(ex)
 
 	mux := http.NewServeMux()
@@ -102,8 +104,6 @@ func TestGetActionsAndStart(t *testing.T) {
 
 	log.Infof("GetReadyz response: %v", respGetReady.Msg)
 
-	assert.Equal(t, true, true, "sayHello Failed")
-
 	//	assert.Equal(t, 1, len(respGb.Msg.Actions), "Got 1 action button back")
 
 	log.Printf("Response: %+v", respInit)
@@ -112,7 +112,7 @@ func TestGetActionsAndStart(t *testing.T) {
 		//		ActionId: "blat"
 	}))
 
-	assert.NotNil(t, err, "Error 404 after start action")
+	require.Error(t, err, "Error 404 after start action")
 	assert.Nil(t, respSa, "Nil response for non existing action")
 
 	defer conn.Close()
@@ -137,12 +137,12 @@ func TestGetEntities(t *testing.T) {
 
 	resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{}))
 
-	assert.NoError(t, err, "GetEntities should not return an error")
-	assert.NotNil(t, resp, "GetEntities response should not be nil")
-	assert.NotNil(t, resp.Msg, "GetEntities response message should not be nil")
+	require.NoError(t, err, "GetEntities should not return an error")
+	require.NotNil(t, resp, "GetEntities response should not be nil")
+	require.NotNil(t, resp.Msg, "GetEntities response message should not be nil")
 
 	entityDefinitions := resp.Msg.EntityDefinitions
-	assert.Equal(t, 3, len(entityDefinitions), "Should return 3 entity definitions")
+	require.Len(t, entityDefinitions, 3, "Should return 3 entity definitions")
 
 	validateEntityOrderAndStructure(t, entityDefinitions)
 	validateNoDuplicates(t, entityDefinitions)
@@ -151,6 +151,8 @@ func TestGetEntities(t *testing.T) {
 }
 
 func validateEntityListProperties(t *testing.T, client apiv1connect.OliveTinApiServiceClient) {
+	t.Helper()
+
 	resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
 		EntityType: "server",
 		Page:       1,
@@ -185,21 +187,27 @@ func setupTestEntities() {
 }
 
 func validateEntityOrderAndStructure(t *testing.T, entityDefinitions []*apiv1.EntityDefinition) {
+	t.Helper()
+
+	require.GreaterOrEqual(t, len(entityDefinitions), 3, "Need at least three entity definitions before indexing")
+
 	assert.Equal(t, "application", entityDefinitions[0].Title, "First entity should be 'application' (alphabetically first)")
-	assert.Equal(t, 1, len(entityDefinitions[0].Instances), "Application should have 1 instance")
+	assert.Len(t, entityDefinitions[0].Instances, 1, "Application should have 1 instance")
 	assert.Equal(t, "webapp", entityDefinitions[0].Instances[0].UniqueKey, "Application instance should be 'webapp'")
 
 	assert.Equal(t, "database", entityDefinitions[1].Title, "Second entity should be 'database' (alphabetically second)")
-	assert.Equal(t, 2, len(entityDefinitions[1].Instances), "Database should have 2 instances")
+	assert.Len(t, entityDefinitions[1].Instances, 2, "Database should have 2 instances")
 	assert.Equal(t, "mysql", entityDefinitions[1].Instances[0].UniqueKey, "First database instance should be 'mysql' (alphabetically first)")
 	assert.Equal(t, "postgres", entityDefinitions[1].Instances[1].UniqueKey, "Second database instance should be 'postgres' (alphabetically second)")
 
 	assert.Equal(t, "server", entityDefinitions[2].Title, "Third entity should be 'server' (alphabetically third)")
-	assert.Equal(t, 0, len(entityDefinitions[2].Instances), "Server instances should not be included in bulk list response")
+	assert.Empty(t, entityDefinitions[2].Instances, "Server instances should not be included in bulk list response")
 	assert.Equal(t, int32(3), entityDefinitions[2].TotalInstances, "Server should report total instance count")
 }
 
 func validateNoDuplicates(t *testing.T, entityDefinitions []*apiv1.EntityDefinition) {
+	t.Helper()
+
 	instanceKeys := make(map[string]map[string]bool)
 	for _, def := range entityDefinitions {
 		instanceKeys[def.Title] = make(map[string]bool)
@@ -211,13 +219,16 @@ func validateNoDuplicates(t *testing.T, entityDefinitions []*apiv1.EntityDefinit
 }
 
 func validateConsistency(t *testing.T, client apiv1connect.OliveTinApiServiceClient, entityDefinitions []*apiv1.EntityDefinition) {
+	t.Helper()
+
 	resp2, err2 := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{}))
-	assert.NoError(t, err2, "Second GetEntities call should not return an error")
-	assert.Equal(t, len(entityDefinitions), len(resp2.Msg.EntityDefinitions), "Second call should return same number of entity definitions")
+	require.NoError(t, err2, "Second GetEntities call should not return an error")
+	require.NotNil(t, resp2.Msg)
+	require.Len(t, resp2.Msg.EntityDefinitions, len(entityDefinitions), "Second call should return same number of entity definitions")
 
 	for i, def := range entityDefinitions {
 		assert.Equal(t, def.Title, resp2.Msg.EntityDefinitions[i].Title, "Entity order should be consistent across calls")
-		assert.Equal(t, len(def.Instances), len(resp2.Msg.EntityDefinitions[i].Instances), "Instance count should be consistent")
+		require.Len(t, resp2.Msg.EntityDefinitions[i].Instances, len(def.Instances), "Instance count should be consistent")
 		for j, inst := range def.Instances {
 			assert.Equal(t, inst.UniqueKey, resp2.Msg.EntityDefinitions[i].Instances[j].UniqueKey, "Instance order should be consistent across calls")
 		}
@@ -226,9 +237,9 @@ func validateConsistency(t *testing.T, client apiv1connect.OliveTinApiServiceCli
 
 func TestEvaluateEnabledExpression(t *testing.T) {
 	tests := []struct {
+		entity         *entities.Entity
 		name           string
 		expression     string
-		entity         *entities.Entity
 		expectedResult bool
 	}{
 		{
@@ -376,6 +387,8 @@ func findBindingByTitle(ex *executor.Executor, title string) *executor.ActionBin
 }
 
 func testWithEntity(t *testing.T, binding *executor.ActionBinding, rr *DashboardRenderRequest, enabled bool, expectedCanExec bool, message string) {
+	t.Helper()
+
 	binding.Entity = &entities.Entity{
 		UniqueKey: "test-entity",
 		Data:      map[string]any{"enabled": enabled},
@@ -782,6 +795,46 @@ func TestEventStreamACLNoLeakToUnauthorizedUser(t *testing.T) {
 	assertEventStreamAdminReceivesSecretActionEvents(t, adminEvents)
 }
 
+func TestRegisterStreamingClientEnforcesLimit(t *testing.T) {
+	cfg := config.DefaultConfig()
+	ex := executor.DefaultExecutor(cfg)
+	api := newServer(ex)
+	user := &authpublic.AuthenticatedUser{Username: "limit-test"}
+
+	clients := make([]*streamingClient, 0, maxEventStreamClients)
+	for i := 0; i < maxEventStreamClients; i++ {
+		client := &streamingClient{
+			channel:           make(chan *apiv1.EventStreamResponse, 1),
+			AuthenticatedUser: user,
+			heartbeatStop:     make(chan struct{}),
+			heartbeatDone:     make(chan struct{}),
+		}
+		close(client.heartbeatDone)
+		require.NoError(t, api.registerStreamingClient(client))
+		clients = append(clients, client)
+	}
+
+	overflow := &streamingClient{
+		channel:           make(chan *apiv1.EventStreamResponse, 1),
+		AuthenticatedUser: user,
+		heartbeatStop:     make(chan struct{}),
+		heartbeatDone:     make(chan struct{}),
+	}
+	close(overflow.heartbeatDone)
+	err := api.registerStreamingClient(overflow)
+	require.ErrorIs(t, err, errEventStreamClientLimit)
+	assert.Len(t, api.streamingClients, maxEventStreamClients)
+
+	api.removeClient(clients[0])
+	require.NoError(t, api.registerStreamingClient(overflow))
+	assert.Len(t, api.streamingClients, maxEventStreamClients)
+
+	for _, client := range clients[1:] {
+		api.removeClient(client)
+	}
+	api.removeClient(overflow)
+}
+
 func addEventStreamTestClients(t *testing.T, api *oliveTinAPI, lowUser, adminUser *authpublic.AuthenticatedUser) (*streamingClient, *streamingClient) {
 	t.Helper()
 	clientLow := &streamingClient{

+ 55 - 0
service/internal/api/dashboard_acl_test.go

@@ -68,6 +68,61 @@ func TestDashboardAclsRootNavAndGetDashboard(t *testing.T) {
 	assert.Equal(t, "Services", db.Title)
 }
 
+func TestRootDashboardEntriesIncludeCategory(t *testing.T) {
+	cfg := config.DefaultConfig()
+	cfg.Dashboards = []*config.DashboardComponent{
+		{
+			Title:    "Misc Tools",
+			Contents: []*config.DashboardComponent{{Title: "Hello", Type: "display"}},
+		},
+		{
+			Title:    "My Servers",
+			Category: "Infrastructure",
+			Contents: []*config.DashboardComponent{{Title: "Ping", Type: "display"}},
+		},
+		{
+			Title:    "Status Board",
+			Category: "Monitoring",
+			Contents: []*config.DashboardComponent{{Title: "Uptime", Type: "display"}},
+		},
+		{
+			Title:    "My Containers",
+			Category: "Infrastructure",
+			Contents: []*config.DashboardComponent{{Title: "Restart", Type: "display"}},
+		},
+	}
+
+	ex := executor.DefaultExecutor(cfg)
+	api := newServer(ex)
+	user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	user.BuildUserAcls(cfg)
+
+	entries := api.buildRootDashboardEntries(user, cfg.Dashboards)
+	require.Len(t, entries, 4)
+	assert.Equal(t, []string{"Misc Tools", "My Servers", "Status Board", "My Containers"}, rootDashboardTitles(entries))
+	assert.Equal(t, "", entries[0].Category)
+	assert.Equal(t, "Infrastructure", entries[1].Category)
+	assert.Equal(t, "Monitoring", entries[2].Category)
+	assert.Equal(t, "Infrastructure", entries[3].Category)
+}
+
+func TestRootDashboardEntriesOmitAclHiddenCategories(t *testing.T) {
+	cfg := buildDashboardAclTestConfig()
+	cfg.Dashboards[0].Category = "Public"
+	cfg.Dashboards[1].Category = "Admin only"
+
+	ex := executor.DefaultExecutor(cfg)
+	api := newServer(ex)
+
+	guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
+	guest.BuildUserAcls(cfg)
+
+	entries := api.buildRootDashboardEntries(guest, cfg.Dashboards)
+	require.Len(t, entries, 1)
+	assert.Equal(t, "Public tools", entries[0].Title)
+	assert.Equal(t, "Public", entries[0].Category)
+}
+
 func TestDashboardAclsNestedDirectoryDeepLink(t *testing.T) {
 	cfg := buildDashboardAclTestConfig()
 	cfg.Dashboards = []*config.DashboardComponent{

+ 1 - 1
service/internal/api/dashboards.go

@@ -10,7 +10,7 @@ import (
 	entities "github.com/OliveTin/OliveTin/internal/entities"
 	"github.com/OliveTin/OliveTin/internal/tpl"
 	log "github.com/sirupsen/logrus"
-	"golang.org/x/exp/slices"
+	"slices"
 )
 
 func renderDashboard(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.Dashboard {

+ 5 - 8
service/internal/auth/authpublic/authenticateduser.go

@@ -10,15 +10,12 @@ import (
 
 // User represents a person.
 type AuthenticatedUser struct {
-	Username      string
-	UsergroupLine string
-
-	Provider string
-	SID      string
-
-	Acls []string
-
 	EffectivePolicy *config.ConfigurationPolicy
+	Username        string
+	UsergroupLine   string
+	Provider        string
+	SID             string
+	Acls            []string
 }
 
 func (u *AuthenticatedUser) IsGuest() bool {

+ 1 - 1
service/internal/auth/authpublic/authenticateduser_test.go

@@ -10,8 +10,8 @@ func Test_parseUsergroupLine(t *testing.T) {
 	tests := []struct {
 		name           string
 		usergroupLine  string
-		expectedGroups []string
 		sep            string
+		expectedGroups []string
 	}{
 		{
 			name:           "Default separator (space)",

+ 5 - 5
service/internal/auth/local_bearer_test.go

@@ -21,7 +21,7 @@ func TestCheckUserFromLocalBearerApiKey_Match_LowercaseBearerScheme(t *testing.T
 		ApiKey:    "secret-api-key",
 	}}
 
-	req := httptest.NewRequest("POST", "/", nil)
+	req := httptest.NewRequestWithContext(t.Context(), "POST", "/", nil)
 	req.Header.Set("Authorization", "bearer secret-api-key")
 
 	ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
@@ -43,7 +43,7 @@ func TestCheckUserFromLocalBearerApiKey_Match(t *testing.T) {
 		ApiKey:    "secret-api-key",
 	}}
 
-	req := httptest.NewRequest("POST", "/", nil)
+	req := httptest.NewRequestWithContext(t.Context(), "POST", "/", nil)
 	req.Header.Set("Authorization", "Bearer secret-api-key")
 
 	ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
@@ -64,7 +64,7 @@ func TestCheckUserFromLocalBearerApiKey_WrongKey(t *testing.T) {
 		ApiKey:   "secret-api-key",
 	}}
 
-	req := httptest.NewRequest("POST", "/", nil)
+	req := httptest.NewRequestWithContext(t.Context(), "POST", "/", nil)
 	req.Header.Set("Authorization", "Bearer wrong")
 
 	ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
@@ -81,7 +81,7 @@ func TestCheckUserFromLocalBearerApiKey_DisabledLocalUsers(t *testing.T) {
 		ApiKey:   "secret-api-key",
 	}}
 
-	req := httptest.NewRequest("POST", "/", nil)
+	req := httptest.NewRequestWithContext(t.Context(), "POST", "/", nil)
 	req.Header.Set("Authorization", "Bearer secret-api-key")
 
 	ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
@@ -98,7 +98,7 @@ func TestCheckUserFromLocalBearerApiKey_NoBearerPrefix(t *testing.T) {
 		ApiKey:   "secret-api-key",
 	}}
 
-	req := httptest.NewRequest("POST", "/", nil)
+	req := httptest.NewRequestWithContext(t.Context(), "POST", "/", nil)
 	req.Header.Set("Authorization", "secret-api-key")
 
 	ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}

+ 3 - 3
service/internal/auth/otjwt/jwt.go

@@ -156,7 +156,7 @@ func parseJwtTokenWithLocalKey(cfg *config.Config, jwtString string) (*jwt.Token
 		return nil, err
 	}
 
-	keyFunc := func(token *jwt.Token) (interface{}, error) {
+	keyFunc := func(token *jwt.Token) (any, error) {
 		if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
 			return nil, fmt.Errorf("parseJwt expected token algorithm RSA but got: %v", token.Header["alg"])
 		}
@@ -170,7 +170,7 @@ func parseJwtTokenWithLocalKey(cfg *config.Config, jwtString string) (*jwt.Token
 
 // Hash-based Message Authentication Code
 func parseJwtTokenWithHMAC(cfg *config.Config, jwtString string) (*jwt.Token, error) {
-	keyFunc := func(token *jwt.Token) (interface{}, error) {
+	keyFunc := func(token *jwt.Token) (any, error) {
 		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
 			return nil, fmt.Errorf("parseJwt expected token algorithm HMAC but got: %v", token.Header["alg"])
 		}
@@ -237,7 +237,7 @@ func parseJwt(cfg *config.Config, token string) *authTypes.AuthenticatedUser {
 func parseGroupClaim(groupClaim string, claims jwt.MapClaims) string {
 	usergroup := ""
 	if val, ok := claims[groupClaim]; ok {
-		if array, ok := val.([]interface{}); ok {
+		if array, ok := val.([]any); ok {
 			groups := make([]string, len(array))
 			for i, v := range array {
 				groups[i] = fmt.Sprintf("%s", v)

+ 34 - 16
service/internal/auth/otjwt/jwt_test.go

@@ -17,9 +17,12 @@ import (
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/golang-jwt/jwt/v5"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func generateRSAKeyPair(t *testing.T) (*rsa.PrivateKey, []byte) {
+	t.Helper()
+
 	privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
 	if err != nil {
 		t.Fatalf("failed to generate RSA key: %v", err)
@@ -42,6 +45,8 @@ func generateRSAKeyPair(t *testing.T) (*rsa.PrivateKey, []byte) {
 }
 
 func createKeys(t *testing.T) (*rsa.PrivateKey, string) {
+	t.Helper()
+
 	tmpFile, err := os.CreateTemp(os.TempDir(), "olivetin-jwt-")
 	if err != nil {
 		t.Fatalf("failed to create temp file: %v", err)
@@ -66,6 +71,8 @@ func newMux() *http.ServeMux {
 }
 
 func createJWTTokenWithExpirationAndAudience(t *testing.T, privateKey *rsa.PrivateKey, expire int64, audience string) string {
+	t.Helper()
+
 	token := jwt.New(jwt.SigningMethodRS256)
 	claims := token.Claims.(jwt.MapClaims)
 	claims["nbf"] = time.Now().Unix() - 1000
@@ -84,6 +91,8 @@ func createJWTTokenWithExpirationAndAudience(t *testing.T, privateKey *rsa.Priva
 }
 
 func setupJWTTestHandler(t *testing.T, cfg *config.Config) http.Handler {
+	t.Helper()
+
 	mux := newMux()
 	mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
 		context := &authpublic.AuthCheckingContext{
@@ -93,7 +102,7 @@ func setupJWTTestHandler(t *testing.T, cfg *config.Config) http.Handler {
 		user := CheckUserFromJwtHeader(context)
 
 		if user == nil {
-			w.WriteHeader(403)
+			w.WriteHeader(http.StatusForbidden)
 			return
 		}
 
@@ -104,17 +113,24 @@ func setupJWTTestHandler(t *testing.T, cfg *config.Config) http.Handler {
 }
 
 func verifyJWTResponse(t *testing.T, res *http.Response, expectCode int) {
+	t.Helper()
+
 	defer func() { _ = res.Body.Close() }()
 	assert.Equal(t, expectCode, res.StatusCode)
-	body, _ := io.ReadAll(res.Body)
+	body, err := io.ReadAll(res.Body)
+	require.NoError(t, err, "reading JWT response body")
 	t.Logf("Response body: %s", string(body))
 }
 
 func testJwkValidation(t *testing.T, expire int64, expectCode int) {
+	t.Helper()
+
 	testJwkValidationWithAudience(t, expire, expectCode, "", "")
 }
 
 func testJwkValidationWithAudience(t *testing.T, expire int64, expectCode int, configAudience, tokenAudience string) {
+	t.Helper()
+
 	privateKey, publicKeyPath := createKeys(t)
 	defer func() { _ = os.Remove(publicKeyPath) }()
 
@@ -131,27 +147,30 @@ func testJwkValidationWithAudience(t *testing.T, expire int64, expectCode int, c
 	srv := httptest.NewServer(handler)
 	defer srv.Close()
 
-	res := makeJWTRequest(t, srv, tokenStr)
+	res := makeJWTRequest(t, srv, tokenStr) //nolint:bodyclose // closed by verifyJWTResponse
+
 	verifyJWTResponse(t, res, expectCode)
 }
 
 func TestJWTSignatureVerificationSucceeds(t *testing.T) {
-	testJwkValidation(t, 1000, 200)
+	testJwkValidation(t, 1000, http.StatusOK)
 }
 
 func TestJWTSignatureVerificationFails(t *testing.T) {
-	testJwkValidation(t, -500, 403)
+	testJwkValidation(t, -500, http.StatusForbidden)
 }
 
 func TestJWTAudienceValidationRejectsWrongAudience(t *testing.T) {
-	testJwkValidationWithAudience(t, 1000, 403, "expected-audience", "wrong-audience")
+	testJwkValidationWithAudience(t, 1000, http.StatusForbidden, "expected-audience", "wrong-audience")
 }
 
 func TestJWTAudienceValidationAcceptsCorrectAudience(t *testing.T) {
-	testJwkValidationWithAudience(t, 1000, 200, "expected-audience", "expected-audience")
+	testJwkValidationWithAudience(t, 1000, http.StatusOK, "expected-audience", "expected-audience")
 }
 
-func createJWTTokenWithGroups(t *testing.T, privateKey *rsa.PrivateKey, groups interface{}) string {
+func createJWTTokenWithGroups(t *testing.T, privateKey *rsa.PrivateKey, groups any) string {
+	t.Helper()
+
 	token := jwt.New(jwt.SigningMethodRS256)
 	claims := token.Claims.(jwt.MapClaims)
 	claims["nbf"] = time.Now().Unix() - 1000
@@ -167,7 +186,9 @@ func createJWTTokenWithGroups(t *testing.T, privateKey *rsa.PrivateKey, groups i
 }
 
 func makeJWTRequest(t *testing.T, srv *httptest.Server, tokenStr string) *http.Response {
-	req, err := http.NewRequest("GET", srv.URL, nil)
+	t.Helper()
+
+	req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil)
 	if err != nil {
 		t.Fatalf("failed to create request: %v", err)
 	}
@@ -177,6 +198,7 @@ func makeJWTRequest(t *testing.T, srv *httptest.Server, tokenStr string) *http.R
 	if err != nil {
 		t.Fatalf("Client err: %+v", err)
 	}
+
 	return res
 }
 
@@ -201,7 +223,7 @@ func TestJWTHeader(t *testing.T) {
 		user := CheckUserFromJwtHeader(context)
 
 		if user == nil {
-			w.WriteHeader(403)
+			w.WriteHeader(http.StatusForbidden)
 			return
 		}
 
@@ -212,10 +234,6 @@ func TestJWTHeader(t *testing.T) {
 	srv := httptest.NewServer(mux)
 	defer srv.Close()
 
-	res := makeJWTRequest(t, srv, tokenStr)
-	defer func() { _ = res.Body.Close() }()
-
-	assert.Equal(t, 200, res.StatusCode)
-	body, _ := io.ReadAll(res.Body)
-	t.Logf("Response body: %s", string(body))
+	res := makeJWTRequest(t, srv, tokenStr) //nolint:bodyclose // closed by verifyJWTResponse
+	verifyJWTResponse(t, res, http.StatusOK)
 }

+ 14 - 3
service/internal/auth/otoauth2/restapi_auth_oauth2.go

@@ -22,9 +22,9 @@ import (
 
 type OAuth2Handler struct {
 	cfg                 *config.Config
-	mu                  sync.RWMutex
 	registeredStates    map[string]*oauth2State
 	registeredProviders map[string]*oauth2.Config
+	mu                  sync.RWMutex
 }
 
 func NewOAuth2Handler(cfg *config.Config) *OAuth2Handler {
@@ -58,11 +58,11 @@ func NewOAuth2Handler(cfg *config.Config) *OAuth2Handler {
 }
 
 type oauth2State struct {
+	createdAt      time.Time
 	providerConfig *oauth2.Config
 	providerName   string
 	Username       string
 	Usergroup      string
-	createdAt      time.Time
 }
 
 const (
@@ -342,7 +342,18 @@ type UserInfo struct {
 func getUserInfo(cfg *config.Config, client *http.Client, provider *config.OAuth2Provider) *UserInfo {
 	ret := &UserInfo{}
 
-	res, err := client.Get(provider.WhoamiUrl)
+	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+
+	defer cancel()
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, provider.WhoamiUrl, nil)
+
+	if err != nil {
+		log.Error("Could not construct user data request", err)
+		return ret
+	}
+
+	res, err := client.Do(req)
 
 	if err != nil {
 		log.Errorf("Failed to get user data: %v", err)

+ 1 - 1
service/internal/auth/otoauth2/restapi_auth_oauth2_test.go

@@ -56,7 +56,7 @@ func TestHandleOAuthLoginRejectsWhenStateMapFull(t *testing.T) {
 		}
 	}
 
-	req := httptest.NewRequest(http.MethodGet, "/oauth/login?provider=test", nil)
+	req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oauth/login?provider=test", nil)
 	rec := httptest.NewRecorder()
 
 	h.HandleOAuthLogin(rec, req)

+ 90 - 92
service/internal/config/config.go

@@ -13,34 +13,33 @@ const JustificationRequiredNoTemplate = " "
 // Action represents the core functionality of OliveTin - commands that show up
 // as buttons in the UI.
 type Action struct {
-	ID                     string           `koanf:"id"`
-	Title                  string           `koanf:"title"`
-	Icon                   string           `koanf:"icon"`
+	SaveLogs               SaveLogsConfig   `koanf:"saveLogs"`
 	Shell                  string           `koanf:"shell"`
-	Exec                   []string         `koanf:"exec"`
+	Icon                   string           `koanf:"icon"`
+	ExecOnCalendarFile     string           `koanf:"execOnCalendarFile"`
+	SourceFile             string           `koanf:"-"`
 	ShellAfterCompleted    string           `koanf:"shellAfterCompleted"`
-	Timeout                int              `koanf:"timeout"`
-	Acls                   []string         `koanf:"acls"`
+	Justification          string           `koanf:"justification"`
+	EnabledExpression      string           `koanf:"enabledExpression"`
 	Entity                 string           `koanf:"entity"`
-	Hidden                 bool             `koanf:"hidden"`
-	ExecOnStartup          bool             `koanf:"execOnStartup"`
-	ExecOnCron             []string         `koanf:"execOnCron"`
-	ExecOnFileCreatedInDir []string         `koanf:"execOnFileCreatedInDir"`
-	ExecOnFileChangedInDir []string         `koanf:"execOnFileChangedInDir"`
-	ExecOnCalendarFile     string           `koanf:"execOnCalendarFile"`
+	Title                  string           `koanf:"title"`
+	PopupOnStart           string           `koanf:"popupOnStart"`
+	OnClick                string           `koanf:"onclick"`
+	ID                     string           `koanf:"id"`
+	MaxRate                []RateSpec       `koanf:"maxRate"`
+	Acls                   []string         `koanf:"acls"`
 	ExecOnWebhook          []WebhookConfig  `koanf:"execOnWebhook"`
 	Triggers               []string         `koanf:"triggers"`
-	MaxConcurrent          int              `koanf:"maxConcurrent"`
-	MaxRate                []RateSpec       `koanf:"maxRate"`
+	Exec                   []string         `koanf:"exec"`
+	ExecOnFileCreatedInDir []string         `koanf:"execOnFileCreatedInDir"`
 	Arguments              []ActionArgument `koanf:"arguments"`
-	OnClick                string           `koanf:"onclick"`
-	PopupOnStart           string           `koanf:"popupOnStart"`
-	SaveLogs               SaveLogsConfig   `koanf:"saveLogs"`
-	EnabledExpression      string           `koanf:"enabledExpression"`
+	ExecOnCron             []string         `koanf:"execOnCron"`
 	Groups                 []string         `koanf:"groups"`
-	Justification          string           `koanf:"justification"`
-	// SourceFile is set by OliveTin when loading config (not user YAML).
-	SourceFile string `koanf:"-"`
+	ExecOnFileChangedInDir []string         `koanf:"execOnFileChangedInDir"`
+	Timeout                int              `koanf:"timeout"`
+	MaxConcurrent          int              `koanf:"maxConcurrent"`
+	Hidden                 bool             `koanf:"hidden"`
+	ExecOnStartup          bool             `koanf:"execOnStartup"`
 }
 
 func (action *Action) RequiresJustification() bool {
@@ -61,23 +60,23 @@ func (action *Action) JustificationTemplateText() string {
 
 // ActionGroup defines shared limits and metadata for a set of actions.
 type ActionGroup struct {
+	Icon          string `koanf:"icon"`
 	MaxConcurrent int    `koanf:"maxConcurrent"`
 	QueueSize     int    `koanf:"queueSize"`
-	Icon          string `koanf:"icon"`
 }
 
 // ActionArgument objects appear on Actions.
 type ActionArgument struct {
+	Suggestions           map[string]string      `koanf:"suggestions"`
 	Name                  string                 `koanf:"name"`
 	Title                 string                 `koanf:"title"`
 	Description           string                 `koanf:"description"`
 	Type                  string                 `koanf:"type"`
 	Default               string                 `koanf:"default"`
-	Choices               []ActionArgumentChoice `koanf:"choices"`
 	Entity                string                 `koanf:"entity"`
-	RejectNull            bool                   `koanf:"rejectNull"`
-	Suggestions           map[string]string      `koanf:"suggestions"`
 	SuggestionsBrowserKey string                 `koanf:"suggestionsBrowserKey"`
+	Choices               []ActionArgumentChoice `koanf:"choices"`
+	RejectNull            bool                   `koanf:"rejectNull"`
 }
 
 // ActionArgumentChoice represents a predefined choice for an argument.
@@ -88,8 +87,8 @@ type ActionArgumentChoice struct {
 
 // RateSpec allows you to set a max frequency for an action.
 type RateSpec struct {
-	Limit    int    `koanf:"limit"`
 	Duration string `koanf:"duration"`
+	Limit    int    `koanf:"limit"`
 }
 
 // WebhookConfig defines configuration for generic webhook triggers.
@@ -111,9 +110,8 @@ type EntityFile struct {
 	File       string           `koanf:"file"`
 	Name       string           `koanf:"name"`
 	Icon       string           `koanf:"icon"`
+	SourceFile string           `koanf:"-"`
 	Properties []EntityProperty `koanf:"properties"`
-	// SourceFile is set by OliveTin when loading config (not user YAML).
-	SourceFile string `koanf:"-"`
 }
 
 // EntityProperty defines a column shown when listing entity instances in the UI.
@@ -133,11 +131,11 @@ type PermissionsList struct {
 // AccessControlList defines what permissions apply to a user or user group.
 type AccessControlList struct {
 	Name             string              `koanf:"name"`
-	AddToEveryAction bool                `koanf:"addToEveryAction"`
 	MatchUsergroups  []string            `koanf:"matchUsergroups"`
 	MatchUsernames   []string            `koanf:"matchUsernames"`
 	Permissions      PermissionsList     `koanf:"permissions"`
 	Policy           ConfigurationPolicy `koanf:"policy"`
+	AddToEveryAction bool                `koanf:"addToEveryAction"`
 }
 
 // ConfigurationPolicy defines global settings which are overridden with an ACL.
@@ -154,88 +152,87 @@ type PrometheusConfig struct {
 
 // SecurityConfig allows users to fine tune the security related HTTP headers and cookie options.
 type SecurityConfig struct {
-	HeaderContentSecurityPolicy bool   `koanf:"headerContentSecurityPolicy"`
 	ContentSecurityPolicy       string `koanf:"contentSecurityPolicy"`
+	XFrameOptions               string `koanf:"xFrameOptions"`
+	HeaderContentSecurityPolicy bool   `koanf:"headerContentSecurityPolicy"`
 	HeaderXContentTypeOptions   bool   `koanf:"headerXContentTypeOptions"`
 	HeaderXFrameOptions         bool   `koanf:"headerXFrameOptions"`
-	XFrameOptions               string `koanf:"xFrameOptions"`
 	ForceSecureCookies          bool   `koanf:"forceSecureCookies"`
 }
 
 // Config is the global config used through the whole app.
 type Config struct {
-	UseSingleHTTPFrontend              bool                       `koanf:"useSingleHTTPFrontend"`
-	ThemeName                          string                     `koanf:"themeName"`
-	ThemeCacheDisabled                 bool                       `koanf:"themeCacheDisabled"`
-	ListenAddressSingleHTTPFrontend    string                     `koanf:"listenAddressSingleHTTPFrontend"`
-	ListenAddressWebUI                 string                     `koanf:"listenAddressWebUI"`
+	ActionGroups                       map[string]*ActionGroup    `koanf:"actionGroups"`
+	AuthOAuth2Providers                map[string]*OAuth2Provider `koanf:"authOAuth2Providers"`
+	SaveLogs                           SaveLogsConfig             `koanf:"saveLogs"`
+	DefaultIconForBack                 string                     `koanf:"defaultIconForBack"`
+	AuthOAuth2RedirectURL              string                     `koanf:"authOAuth2RedirectUrl"`
 	ListenAddressRestActions           string                     `koanf:"listenAddressRestActions"`
 	ListenAddressPrometheus            string                     `koanf:"listenAddressPrometheus"`
 	ExternalRestAddress                string                     `koanf:"externalRestAddress"`
 	LogLevel                           string                     `koanf:"logLevel"`
-	LogDebugOptions                    LogDebugOptions            `koanf:"logDebugOptions"`
-	LogHistoryPageSize                 int64                      `koanf:"logHistoryPageSize"`
-	ActionGroups                       map[string]*ActionGroup    `koanf:"actionGroups"`
-	Actions                            []*Action                  `koanf:"actions"`
-	Entities                           []*EntityFile              `koanf:"entities"`
-	Dashboards                         []*DashboardComponent      `koanf:"dashboards"`
-	CheckForUpdates                    bool                       `koanf:"checkForUpdates"`
+	ThemeName                          string                     `koanf:"themeName"`
+	ServiceLogs                        ServiceLogsConfig          `koanf:"serviceLogs"`
+	ListenAddressSingleHTTPFrontend    string                     `koanf:"listenAddressSingleHTTPFrontend"`
+	AuthJwtHmacSecret                  string                     `koanf:"authJwtHmacSecret"`
+	AuthJwtCertsURL                    string                     `koanf:"authJwtCertsUrl"`
+	DefaultIconForActions              string                     `koanf:"defaultIconForActions"`
+	Include                            string                     `koanf:"include"`
 	PageTitle                          string                     `koanf:"pageTitle"`
-	ShowFooter                         bool                       `koanf:"showFooter"`
-	ShowNavigation                     bool                       `koanf:"showNavigation"`
-	ShowNewVersions                    bool                       `koanf:"showNewVersions"`
-	ShowNavigateOnStartIcons           bool                       `koanf:"showNavigateOnStartIcons"`
-	EnableCustomJs                     bool                       `koanf:"enableCustomJs"`
+	BannerCSS                          string                     `koanf:"bannerCss"`
+	BannerMessage                      string                     `koanf:"bannerMessage"`
+	DefaultPopupOnStart                string                     `koanf:"defaultPopupOnStart"`
+	ServiceHostMode                    string                     `koanf:"serviceHostMode"`
+	DefaultOnClick                     string                     `koanf:"defaultOnClick"`
 	AuthJwtCookieName                  string                     `koanf:"authJwtCookieName"`
 	AuthJwtHeader                      string                     `koanf:"authJwtHeader"`
 	AuthJwtAud                         string                     `koanf:"authJwtAud"`
-	AuthJwtDomain                      string                     `koanf:"authJwtDomain"`
-	AuthJwtCertsURL                    string                     `koanf:"authJwtCertsUrl"`
-	AuthJwtHmacSecret                  string                     `koanf:"authJwtHmacSecret"` // mutually exclusive with pub key config fields
+	ListenAddressWebUI                 string                     `koanf:"listenAddressWebUI"`
+	SectionNavigationStyle             string                     `koanf:"sectionNavigationStyle"`
+	DefaultIconForDirectories          string                     `koanf:"defaultIconForDirectories"`
 	AuthJwtClaimUsername               string                     `koanf:"authJwtClaimUsername"`
 	AuthJwtClaimUserGroup              string                     `koanf:"authJwtClaimUserGroup"`
-	AuthJwtPubKeyPath                  string                     `koanf:"authJwtPubKeyPath"` // will read pub key from file on disk
+	AuthJwtPubKeyPath                  string                     `koanf:"authJwtPubKeyPath"`
 	AuthHttpHeaderUsername             string                     `koanf:"authHttpHeaderUsername"`
 	AuthHttpHeaderUserGroup            string                     `koanf:"authHttpHeaderUserGroup"`
 	AuthHttpHeaderUserGroupSep         string                     `koanf:"authHttpHeaderUserGroupSep"`
-	AuthLocalUsers                     AuthLocalUsersConfig       `koanf:"authLocalUsers"`
-	AuthLoginUrl                       string                     `koanf:"authLoginUrl"`
-	AuthRequireGuestsToLogin           bool                       `koanf:"authRequireGuestsToLogin"`
-	AuthOAuth2RedirectURL              string                     `koanf:"authOAuth2RedirectUrl"`
-	AuthOAuth2Providers                map[string]*OAuth2Provider `koanf:"authOAuth2Providers"`
-	DefaultPermissions                 PermissionsList            `koanf:"defaultPermissions"`
-	DefaultPolicy                      ConfigurationPolicy        `koanf:"defaultPolicy"`
-	AccessControlLists                 []*AccessControlList       `koanf:"accessControlLists"`
 	WebUIDir                           string                     `koanf:"webUIDir"`
-	CronSupportForSeconds              bool                       `koanf:"cronSupportForSeconds"`
-	SectionNavigationStyle             string                     `koanf:"sectionNavigationStyle"`
-	DefaultOnClick                     string                     `koanf:"defaultOnClick"`
-	DefaultPopupOnStart                string                     `koanf:"defaultPopupOnStart"`
-	InsecureAllowDumpOAuth2UserData    bool                       `koanf:"insecureAllowDumpOAuth2UserData"`
-	InsecureAllowDumpVars              bool                       `koanf:"insecureAllowDumpVars"`
-	InsecureAllowDumpServerDiagnostics bool                       `koanf:"insecureAllowDumpServerDiagnostics"`
-	InsecureAllowDumpActionMap         bool                       `koanf:"insecureAllowDumpActionMap"`
-	InsecureAllowDumpJwtClaims         bool                       `koanf:"insecureAllowDumpJwtClaims"`
-	Prometheus                         PrometheusConfig           `koanf:"prometheus"`
+	AuthLoginUrl                       string                     `koanf:"authLoginUrl"`
+	AuthJwtDomain                      string                     `koanf:"authJwtDomain"`
 	Security                           SecurityConfig             `koanf:"security"`
-	SaveLogs                           SaveLogsConfig             `koanf:"saveLogs"`
-	ServiceLogs                        ServiceLogsConfig          `koanf:"serviceLogs"`
-	DefaultIconForActions              string                     `koanf:"defaultIconForActions"`
-	DefaultIconForDirectories          string                     `koanf:"defaultIconForDirectories"`
-	DefaultIconForBack                 string                     `koanf:"defaultIconForBack"`
-	AdditionalNavigationLinks          []*NavigationLink          `koanf:"additionalNavigationLinks"`
-	ServiceHostMode                    string                     `koanf:"serviceHostMode"`
+	Actions                            []*Action                  `koanf:"actions"`
+	AccessControlLists                 []*AccessControlList       `koanf:"accessControlLists"`
 	StyleMods                          []string                   `koanf:"styleMods"`
-	BannerMessage                      string                     `koanf:"bannerMessage"`
-	BannerCSS                          string                     `koanf:"bannerCss"`
-	Include                            string                     `koanf:"include"`
-
-	sourceFiles []string
+	AdditionalNavigationLinks          []*NavigationLink          `koanf:"additionalNavigationLinks"`
+	Entities                           []*EntityFile              `koanf:"entities"`
+	Dashboards                         []*DashboardComponent      `koanf:"dashboards"`
+	sourceFiles                        []string
+	AuthLocalUsers                     AuthLocalUsersConfig `koanf:"authLocalUsers"`
+	LogHistoryPageSize                 int64                `koanf:"logHistoryPageSize"`
+	LogDebugOptions                    LogDebugOptions      `koanf:"logDebugOptions"`
+	DefaultPermissions                 PermissionsList      `koanf:"defaultPermissions"`
+	DefaultPolicy                      ConfigurationPolicy  `koanf:"defaultPolicy"`
+	Prometheus                         PrometheusConfig     `koanf:"prometheus"`
+	CheckForUpdates                    bool                 `koanf:"checkForUpdates"`
+	InsecureAllowDumpJwtClaims         bool                 `koanf:"insecureAllowDumpJwtClaims"`
+	InsecureAllowDumpActionMap         bool                 `koanf:"insecureAllowDumpActionMap"`
+	InsecureAllowDumpServerDiagnostics bool                 `koanf:"insecureAllowDumpServerDiagnostics"`
+	InsecureAllowDumpVars              bool                 `koanf:"insecureAllowDumpVars"`
+	InsecureAllowDumpOAuth2UserData    bool                 `koanf:"insecureAllowDumpOAuth2UserData"`
+	CronSupportForSeconds              bool                 `koanf:"cronSupportForSeconds"`
+	AuthRequireGuestsToLogin           bool                 `koanf:"authRequireGuestsToLogin"`
+	EnableCustomJs                     bool                 `koanf:"enableCustomJs"`
+	ShowNavigateOnStartIcons           bool                 `koanf:"showNavigateOnStartIcons"`
+	ShowNewVersions                    bool                 `koanf:"showNewVersions"`
+	ShowNavigation                     bool                 `koanf:"showNavigation"`
+	ShowFooter                         bool                 `koanf:"showFooter"`
+	UseSingleHTTPFrontend              bool                 `koanf:"useSingleHTTPFrontend"`
+	ThemeCacheDisabled                 bool                 `koanf:"themeCacheDisabled"`
 }
 
 type AuthLocalUsersConfig struct {
-	Enabled bool         `koanf:"enabled"`
 	Users   []*LocalUser `koanf:"users"`
+	Enabled bool         `koanf:"enabled"`
 }
 
 type LocalUser struct {
@@ -246,21 +243,21 @@ type LocalUser struct {
 }
 
 type OAuth2Provider struct {
-	Name               string   `koanf:"name"`
-	Title              string   `koanf:"title"`
+	AuthUrl            string   `koanf:"authUrl"`
+	UserGroupField     string   `koanf:"userGroupField"`
 	ClientID           string   `koanf:"clientId"`
 	ClientSecret       string   `koanf:"clientSecret"`
 	Icon               string   `koanf:"icon"`
-	Scopes             []string `koanf:"scopes"`
-	AuthUrl            string   `koanf:"authUrl"`
-	TokenUrl           string   `koanf:"tokenUrl"`
+	AddToUsergroup     string   `koanf:"addToUsergroup"`
+	Title              string   `koanf:"title"`
 	WhoamiUrl          string   `koanf:"whoamiUrl"`
+	Name               string   `koanf:"name"`
 	UsernameField      string   `koanf:"usernameField"`
-	UserGroupField     string   `koanf:"userGroupField"`
-	InsecureSkipVerify bool     `koanf:"insecureSkipVerify"`
-	CallbackTimeout    int      `koanf:"callbackTimeout"`
+	TokenUrl           string   `koanf:"tokenUrl"`
 	CertBundlePath     string   `koanf:"certBundlePath"`
-	AddToUsergroup     string   `koanf:"addToUsergroup"`
+	Scopes             []string `koanf:"scopes"`
+	CallbackTimeout    int      `koanf:"callbackTimeout"`
+	InsecureSkipVerify bool     `koanf:"insecureSkipVerify"`
 }
 
 type NavigationLink struct {
@@ -289,6 +286,7 @@ type LogDebugOptions struct {
 
 type DashboardComponent struct {
 	Title        string                `koanf:"title"`
+	Category     string                `koanf:"category"`
 	Type         string                `koanf:"type"`
 	Entity       string                `koanf:"entity"`
 	Icon         string                `koanf:"icon"`

+ 77 - 15
service/internal/config/config_reloader.go

@@ -2,11 +2,13 @@ package config
 
 import (
 	"fmt"
+	"net"
 	"os"
 	"path/filepath"
 	"reflect"
 	"regexp"
 	"sort"
+	"strconv"
 	"strings"
 
 	"github.com/OliveTin/OliveTin/internal/configissues"
@@ -81,12 +83,72 @@ func afterLoadFinalize(cfg *Config, configPath string) {
 
 	cfg.SetDir(filepath.Dir(configPath))
 	cfg.Sanitize()
+	applyPortEnvironmentOverride(cfg)
 
 	for _, l := range listeners {
 		l()
 	}
 }
 
+// applyPortEnvironmentOverride lets the PORT environment variable take precedence
+// over the configured HTTP frontend port.
+func applyPortEnvironmentOverride(cfg *Config) {
+	envPort := strings.TrimSpace(os.Getenv("PORT"))
+	if envPort == "" {
+		return
+	}
+
+	port, ok := parseEnvPort(envPort)
+	if !ok {
+		return
+	}
+
+	host, ok := listenHostOrDefault(cfg.ListenAddressSingleHTTPFrontend)
+	if !ok {
+		log.WithFields(log.Fields{
+			"PORT":          envPort,
+			"listenAddress": cfg.ListenAddressSingleHTTPFrontend,
+		}).Error("Ignoring PORT environment variable because listenAddressSingleHTTPFrontend is invalid")
+		return
+	}
+
+	cfg.ListenAddressSingleHTTPFrontend = net.JoinHostPort(host, strconv.Itoa(port))
+
+	log.WithFields(log.Fields{
+		"address": cfg.ListenAddressSingleHTTPFrontend,
+	}).Info("Using PORT environment variable for single HTTP frontend listen address")
+}
+
+func parseEnvPort(envPort string) (int, bool) {
+	port, err := strconv.Atoi(envPort)
+	if err != nil || port < 1 || port > 65535 {
+		log.WithFields(log.Fields{
+			"PORT":  envPort,
+			"error": err,
+		}).Error("Ignoring invalid PORT environment variable")
+		return 0, false
+	}
+
+	return port, true
+}
+
+func listenHostOrDefault(listenAddress string) (string, bool) {
+	if strings.TrimSpace(listenAddress) == "" {
+		return "0.0.0.0", true
+	}
+
+	host, _, err := net.SplitHostPort(listenAddress)
+	if err != nil {
+		return "", false
+	}
+
+	if host == "" {
+		return "0.0.0.0", true
+	}
+
+	return host, true
+}
+
 // buildIncludePath constructs the full path to the include directory.
 func buildIncludePath(k *koanf.Koanf, baseConfigPath string) string {
 	relativeIncludePath := k.String("include")
@@ -187,17 +249,17 @@ func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
 	}).Info("Successfully loaded included config file")
 }
 
-func mergeFuncForSource(sourceFile string) func(src, dest map[string]interface{}) error {
-	return func(src map[string]interface{}, dest map[string]interface{}) error {
+func mergeFuncForSource(sourceFile string) func(src, dest map[string]any) error {
+	return func(src map[string]any, dest map[string]any) error {
 		return mergeFunc(src, dest, sourceFile)
 	}
 }
 
 // mergeActionsWhenBothExist merges actions when both src and dest have actions.
-func mergeActionsWhenBothExist(srcActions interface{}, destActions interface{}, dest map[string]interface{}, sourceFile string) {
+func mergeActionsWhenBothExist(srcActions any, destActions any, dest map[string]any, sourceFile string) {
 	stampSourceOnMaps(srcActions, sourceFile)
-	srcSlice, ok1 := srcActions.([]interface{})
-	destSlice, ok2 := destActions.([]interface{})
+	srcSlice, ok1 := srcActions.([]any)
+	destSlice, ok2 := destActions.([]any)
 	if ok1 && ok2 {
 		dest["actions"] = append(destSlice, srcSlice...)
 	} else {
@@ -206,7 +268,7 @@ func mergeActionsWhenBothExist(srcActions interface{}, destActions interface{},
 }
 
 // mergeActionsFromSource merges actions from source into destination.
-func mergeActionsFromSource(srcActions interface{}, dest map[string]interface{}, sourceFile string) {
+func mergeActionsFromSource(srcActions any, dest map[string]any, sourceFile string) {
 	if destActions, ok := dest["actions"]; ok {
 		mergeActionsWhenBothExist(srcActions, destActions, dest, sourceFile)
 	} else {
@@ -216,9 +278,9 @@ func mergeActionsFromSource(srcActions interface{}, dest map[string]interface{},
 }
 
 // mergeDashboardsWhenBothExist merges dashboards when both src and dest have dashboards.
-func mergeDashboardsWhenBothExist(srcDashboards interface{}, destDashboards interface{}, dest map[string]interface{}) {
-	srcSlice, ok1 := srcDashboards.([]interface{})
-	destSlice, ok2 := destDashboards.([]interface{})
+func mergeDashboardsWhenBothExist(srcDashboards any, destDashboards any, dest map[string]any) {
+	srcSlice, ok1 := srcDashboards.([]any)
+	destSlice, ok2 := destDashboards.([]any)
 	if ok1 && ok2 {
 		dest["dashboards"] = append(destSlice, srcSlice...)
 	} else {
@@ -227,7 +289,7 @@ func mergeDashboardsWhenBothExist(srcDashboards interface{}, destDashboards inte
 }
 
 // mergeDashboardsFromSource merges dashboards from source into destination.
-func mergeDashboardsFromSource(srcDashboards interface{}, dest map[string]interface{}) {
+func mergeDashboardsFromSource(srcDashboards any, dest map[string]any) {
 	if destDashboards, ok := dest["dashboards"]; ok {
 		mergeDashboardsWhenBothExist(srcDashboards, destDashboards, dest)
 	} else {
@@ -236,10 +298,10 @@ func mergeDashboardsFromSource(srcDashboards interface{}, dest map[string]interf
 }
 
 // mergeEntitiesWhenBothExist merges entities when both src and dest have entities.
-func mergeEntitiesWhenBothExist(srcEntities interface{}, destEntities interface{}, dest map[string]interface{}, sourceFile string) {
+func mergeEntitiesWhenBothExist(srcEntities any, destEntities any, dest map[string]any, sourceFile string) {
 	stampSourceOnMaps(srcEntities, sourceFile)
-	srcSlice, ok1 := srcEntities.([]interface{})
-	destSlice, ok2 := destEntities.([]interface{})
+	srcSlice, ok1 := srcEntities.([]any)
+	destSlice, ok2 := destEntities.([]any)
 	if ok1 && ok2 {
 		dest["entities"] = append(destSlice, srcSlice...)
 	} else {
@@ -248,7 +310,7 @@ func mergeEntitiesWhenBothExist(srcEntities interface{}, destEntities interface{
 }
 
 // mergeEntitiesFromSource merges entities from source into destination.
-func mergeEntitiesFromSource(srcEntities interface{}, dest map[string]interface{}, sourceFile string) {
+func mergeEntitiesFromSource(srcEntities any, dest map[string]any, sourceFile string) {
 	if destEntities, ok := dest["entities"]; ok {
 		mergeEntitiesWhenBothExist(srcEntities, destEntities, dest, sourceFile)
 	} else {
@@ -257,7 +319,7 @@ func mergeEntitiesFromSource(srcEntities interface{}, dest map[string]interface{
 	}
 }
 
-func mergeFunc(src map[string]interface{}, dest map[string]interface{}, sourceFile string) error {
+func mergeFunc(src map[string]any, dest map[string]any, sourceFile string) error {
 	if srcActions, ok := src["actions"]; ok {
 		mergeActionsFromSource(srcActions, dest, sourceFile)
 	}

+ 82 - 0
service/internal/config/port_env_test.go

@@ -0,0 +1,82 @@
+package config
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestApplyPortEnvironmentOverride(t *testing.T) {
+	t.Setenv("PORT", "8080")
+
+	cfg := DefaultConfig()
+	cfg.ListenAddressSingleHTTPFrontend = "0.0.0.0:1337"
+	applyPortEnvironmentOverride(cfg)
+
+	assert.Equal(t, "0.0.0.0:8080", cfg.ListenAddressSingleHTTPFrontend)
+}
+
+func TestApplyPortEnvironmentOverridePreservesHost(t *testing.T) {
+	t.Setenv("PORT", "9000")
+
+	cfg := DefaultConfig()
+	cfg.ListenAddressSingleHTTPFrontend = "127.0.0.1:1337"
+	applyPortEnvironmentOverride(cfg)
+
+	assert.Equal(t, "127.0.0.1:9000", cfg.ListenAddressSingleHTTPFrontend)
+}
+
+func TestApplyPortEnvironmentOverrideUnsetLeavesConfig(t *testing.T) {
+	t.Setenv("PORT", "")
+
+	cfg := DefaultConfig()
+	cfg.ListenAddressSingleHTTPFrontend = "0.0.0.0:2337"
+	applyPortEnvironmentOverride(cfg)
+
+	assert.Equal(t, "0.0.0.0:2337", cfg.ListenAddressSingleHTTPFrontend)
+}
+
+func TestApplyPortEnvironmentOverrideIgnoresInvalid(t *testing.T) {
+	// parseEnvPort accepts only 1..65535; port 0 and out-of-range values are ignored.
+	cases := []struct {
+		name string
+		port string
+	}{
+		{name: "non-numeric", port: "not-a-port"},
+		{name: "above max", port: "65536"},
+		{name: "negative", port: "-1"},
+		{name: "zero ignored", port: "0"},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Setenv("PORT", tc.port)
+
+			cfg := DefaultConfig()
+			cfg.ListenAddressSingleHTTPFrontend = "0.0.0.0:1337"
+			applyPortEnvironmentOverride(cfg)
+
+			assert.Equal(t, "0.0.0.0:1337", cfg.ListenAddressSingleHTTPFrontend)
+		})
+	}
+}
+
+func TestApplyPortEnvironmentOverrideEmptyListenAddressDefaultsHost(t *testing.T) {
+	t.Setenv("PORT", "8080")
+
+	cfg := DefaultConfig()
+	cfg.ListenAddressSingleHTTPFrontend = ""
+	applyPortEnvironmentOverride(cfg)
+
+	assert.Equal(t, "0.0.0.0:8080", cfg.ListenAddressSingleHTTPFrontend)
+}
+
+func TestApplyPortEnvironmentOverrideRejectsMalformedListenAddress(t *testing.T) {
+	t.Setenv("PORT", "8080")
+
+	cfg := DefaultConfig()
+	cfg.ListenAddressSingleHTTPFrontend = "not-a-valid-address"
+	applyPortEnvironmentOverride(cfg)
+
+	assert.Equal(t, "not-a-valid-address", cfg.ListenAddressSingleHTTPFrontend)
+}

+ 3 - 8
service/internal/config/sanitize.go

@@ -2,6 +2,7 @@ package config
 
 import (
 	"fmt"
+	"slices"
 	"strings"
 	"text/template"
 
@@ -179,13 +180,7 @@ func (cfg *Config) inlineActionExists(action *Action) bool {
 }
 
 func (cfg *Config) inlineActionPointerExists(action *Action) bool {
-	for _, existingAction := range cfg.Actions {
-		if existingAction == action {
-			return true
-		}
-	}
-
-	return false
+	return slices.Contains(cfg.Actions, action)
 }
 
 func (cfg *Config) inlineActionIDExists(action *Action) bool {
@@ -400,7 +395,7 @@ func expandEnvTemplate(source string) string {
 		return source
 	}
 	var b strings.Builder
-	if err := t.Execute(&b, map[string]interface{}{"Env": env.BuildEnvMap()}); err != nil {
+	if err := t.Execute(&b, map[string]any{"Env": env.BuildEnvMap()}); err != nil {
 		log.WithFields(log.Fields{"error": err}).Debug("Env template execute failed, using literal")
 		return source
 	}

+ 4 - 4
service/internal/config/source_file.go

@@ -15,7 +15,7 @@ func stampSourceOnMaps(raw any, sourceFile string) any {
 		return raw
 	}
 
-	items, ok := raw.([]interface{})
+	items, ok := raw.([]any)
 	if !ok {
 		return raw
 	}
@@ -27,7 +27,7 @@ func stampSourceOnMaps(raw any, sourceFile string) any {
 }
 
 func stampSourceOnMap(item any, sourceFile string) {
-	m, ok := item.(map[string]interface{})
+	m, ok := item.(map[string]any)
 	if !ok {
 		return
 	}
@@ -74,7 +74,7 @@ func sourcePathAt(paths []string, index int) string {
 }
 
 func stampedSourcePaths(raw any) []string {
-	items, ok := raw.([]interface{})
+	items, ok := raw.([]any)
 	if !ok {
 		return nil
 	}
@@ -87,7 +87,7 @@ func stampedSourcePaths(raw any) []string {
 }
 
 func stampedSourceFromMap(item any) string {
-	m, ok := item.(map[string]interface{})
+	m, ok := item.(map[string]any)
 	if !ok {
 		return ""
 	}

+ 1 - 1
service/internal/entities/entities.go

@@ -69,7 +69,7 @@ func watchAndLoadEntity(baseDir string, ef *config.EntityFile) {
 		p = filepath.Join(baseDir, p)
 		log.WithFields(log.Fields{"entityFile": p}).Debugf("Adding config dir to entity file path")
 	}
-	go filehelper.WatchFileWrite(p, func(filename string) { loadEntityFile(p, ef.Name) }, filehelper.WatchMeta{
+	go filehelper.WatchFileWrite(p, func(_ string) { loadEntityFile(p, ef.Name) }, filehelper.WatchMeta{
 		ConfigFile: ef.SourceFile,
 	})
 	loadEntityFile(p, ef.Name)

+ 5 - 6
service/internal/entities/storage.go

@@ -10,6 +10,7 @@ package entities
  */
 
 import (
+	"maps"
 	"sort"
 	"strconv"
 	"strings"
@@ -39,9 +40,8 @@ func GetEntities() EntitiesByClass {
 	for entityName, entityInstances := range entities {
 		copiedInstances := make(entityInstancesByKey, len(entityInstances))
 
-		for key, entity := range entityInstances {
-			copiedInstances[key] = entity
-		}
+		maps.Copy(copiedInstances, entityInstances)
+
 		copiedEntities[entityName] = copiedInstances
 	}
 
@@ -57,9 +57,8 @@ func GetEntityInstances(entityName string) entityInstancesByKey {
 	if entities, ok := entities[entityName]; ok {
 		copiedInstances := make(entityInstancesByKey, len(entities))
 
-		for key, entity := range entities {
-			copiedInstances[key] = entity
-		}
+		maps.Copy(copiedInstances, entities)
+
 		return copiedInstances
 	}
 

+ 4 - 4
service/internal/executor/arguments.go

@@ -67,7 +67,7 @@ func parseExecSegment(arg string, values map[string]string, entity *entities.Ent
 
 func validateArguments(values map[string]string, action *config.Action) error {
 	for _, arg := range action.Arguments {
-		if err := typecheckActionArgument(&arg, values[arg.Name], action); err != nil {
+		if err := typecheckActionArgument(&arg, values[arg.Name]); err != nil {
 			return err
 		}
 		log.WithFields(log.Fields{"name": arg.Name, "value": values[arg.Name]}).Debugf("Arg assigned")
@@ -90,7 +90,7 @@ func parseActionArguments(req *ExecutionRequest) (string, error) {
 		argName := arg.Name
 		argValue := req.Arguments[argName]
 
-		err := typecheckActionArgument(&arg, argValue, req.Binding.Action)
+		err := typecheckActionArgument(&arg, argValue)
 
 		if err != nil {
 			return "", err
@@ -153,7 +153,7 @@ func argumentSkipsValidation(arg *config.ActionArgument) bool {
 	return arg.Type == "html"
 }
 
-func typecheckActionArgument(arg *config.ActionArgument, value string, action *config.Action) error {
+func typecheckActionArgument(arg *config.ActionArgument, value string) error {
 	if argumentSkipsValidation(arg) {
 		return nil
 	}
@@ -199,7 +199,7 @@ func ValidateArgument(arg *config.ActionArgument, value string, action *config.A
 	mangledValue := MangleArgumentValue(arg, value, action.Title)
 
 	// Use the same validation path as the executor
-	return typecheckActionArgument(arg, mangledValue, action)
+	return typecheckActionArgument(arg, mangledValue)
 }
 
 func typecheckActionArgumentFound(value string, arg *config.ActionArgument) error {

+ 91 - 87
service/internal/executor/arguments_test.go

@@ -12,16 +12,17 @@ import (
 	"testing"
 
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 
 func TestSanitizeUnsafe(t *testing.T) {
-	assert.Nil(t, TypeSafetyCheck("", "_zomg_ c:/ haxxor ' bobby tables && rm -rf ", "very_dangerous_raw_string"))
+	require.NoError(t, TypeSafetyCheck("", "_zomg_ c:/ haxxor ' bobby tables && rm -rf ", "very_dangerous_raw_string"))
 }
 
 func TestSanitizeUnimplemented(t *testing.T) {
 	err := TypeSafetyCheck("", "I am a happy little argument", "greeting_type")
 
-	assert.NotNil(t, err, "Test an argument type that does not exist")
+	require.Error(t, err, "Test an argument type that does not exist")
 }
 
 func TestValidateArgumentCheckboxDefaultValues(t *testing.T) {
@@ -35,10 +36,10 @@ func TestValidateArgumentCheckboxDefaultValues(t *testing.T) {
 
 	// Default checkbox values without choices should accept "1" and "0"
 	err := ValidateArgument(&arg, "1", &action)
-	assert.Nil(t, err, "Expected checkbox value \"1\" to be accepted without choices")
+	require.NoError(t, err, "Expected checkbox value \"1\" to be accepted without choices")
 
 	err = ValidateArgument(&arg, "0", &action)
-	assert.Nil(t, err, "Expected checkbox value \"0\" to be accepted without choices")
+	require.NoError(t, err, "Expected checkbox value \"0\" to be accepted without choices")
 }
 
 func TestMangleCheckboxValueWithChoices(t *testing.T) {
@@ -105,14 +106,14 @@ func TestValidateArgumentCheckboxWithChoices(t *testing.T) {
 
 	// Titles should be accepted once mangled to their values
 	err := ValidateArgument(&arg, "Enabled", &action)
-	assert.Nil(t, err, "Expected checkbox title \"Enabled\" to be accepted after mangling to choice value")
+	require.NoError(t, err, "Expected checkbox title \"Enabled\" to be accepted after mangling to choice value")
 
 	err = ValidateArgument(&arg, "Disabled", &action)
-	assert.Nil(t, err, "Expected checkbox title \"Disabled\" to be accepted after mangling to choice value")
+	require.NoError(t, err, "Expected checkbox title \"Disabled\" to be accepted after mangling to choice value")
 
 	// Unknown titles should be rejected because they do not match any choice value
 	err = ValidateArgument(&arg, "Maybe", &action)
-	assert.NotNil(t, err, "Expected unknown checkbox title to be rejected against choices")
+	require.Error(t, err, "Expected unknown checkbox title to be rejected against choices")
 }
 
 func checklistTestArg() config.ActionArgument {
@@ -134,13 +135,13 @@ func TestValidateArgumentChecklistSelections(t *testing.T) {
 	action := config.Action{Title: "Test checklist"}
 
 	err := ValidateArgument(&arg, "documents", &action)
-	assert.Nil(t, err)
+	require.NoError(t, err)
 
 	err = ValidateArgument(&arg, `["documents","photos"]`, &action)
-	assert.Nil(t, err)
+	require.NoError(t, err)
 
 	err = ValidateArgument(&arg, `["documents","unknown"]`, &action)
-	assert.NotNil(t, err)
+	require.Error(t, err)
 }
 
 func TestValidateArgumentChecklistTitleMangling(t *testing.T) {
@@ -150,7 +151,7 @@ func TestValidateArgumentChecklistTitleMangling(t *testing.T) {
 	action := config.Action{Title: "Test checklist title mangling"}
 
 	err := ValidateArgument(&arg, `["Documents","Photos"]`, &action)
-	assert.Nil(t, err)
+	require.NoError(t, err)
 }
 
 func TestValidateArgumentChecklistEmptySelection(t *testing.T) {
@@ -160,11 +161,11 @@ func TestValidateArgumentChecklistEmptySelection(t *testing.T) {
 	action := config.Action{Title: "Test checklist empty"}
 
 	err := ValidateArgument(&arg, "", &action)
-	assert.Nil(t, err)
+	require.NoError(t, err)
 
 	arg.RejectNull = true
 	err = ValidateArgument(&arg, "", &action)
-	assert.NotNil(t, err)
+	require.Error(t, err)
 }
 
 func TestValidateArgumentChecklistWithoutChoices(t *testing.T) {
@@ -177,7 +178,7 @@ func TestValidateArgumentChecklistWithoutChoices(t *testing.T) {
 	action := config.Action{Title: "Test checklist without choices"}
 
 	err := ValidateArgument(&arg, "documents", &action)
-	assert.NotNil(t, err)
+	require.Error(t, err)
 }
 
 func TestValidateArgumentChecklistRejectsEmptySegment(t *testing.T) {
@@ -187,7 +188,7 @@ func TestValidateArgumentChecklistRejectsEmptySegment(t *testing.T) {
 	action := config.Action{Title: "Test checklist empty segment"}
 
 	err := ValidateArgument(&arg, `["documents","","photos"]`, &action)
-	assert.NotNil(t, err)
+	require.Error(t, err)
 }
 
 func TestMangleArgumentValueChecklist(t *testing.T) {
@@ -223,13 +224,13 @@ func TestValidateArgumentChecklistEntitySelections(t *testing.T) {
 	action := config.Action{Title: "Test checklist entity"}
 
 	err := ValidateArgument(&arg, "attic", &action)
-	assert.Nil(t, err)
+	require.NoError(t, err)
 
 	err = ValidateArgument(&arg, `["attic","basement"]`, &action)
-	assert.Nil(t, err)
+	require.NoError(t, err)
 
 	err = ValidateArgument(&arg, `["attic","unknown"]`, &action)
-	assert.NotNil(t, err)
+	require.Error(t, err)
 }
 
 func TestMangleArgumentValueChecklistEntityTitles(t *testing.T) {
@@ -274,7 +275,7 @@ func TestParseActionArgumentsChecklistEmptySelection(t *testing.T) {
 	mangleInvalidArgumentValues(req)
 	out, err := parseActionArguments(req)
 
-	assert.Nil(t, err)
+	require.NoError(t, err)
 	assert.Equal(t, "echo 'Selected segments: '", out)
 }
 
@@ -307,13 +308,13 @@ func TestArgumentValueNullable(t *testing.T) {
 	out, err := parseActionArguments(req)
 
 	assert.Equal(t, "echo 'Releasing  hounds'", out)
-	assert.Nil(t, err)
+	require.NoError(t, err)
 
 	req.Binding.Action.Arguments[0].RejectNull = true
 
 	_, err = parseActionArguments(req)
 
-	assert.NotNil(t, err)
+	require.Error(t, err)
 }
 
 func TestArgumentNameNumbers(t *testing.T) {
@@ -336,7 +337,7 @@ func TestArgumentNameNumbers(t *testing.T) {
 	out, err := parseActionArguments(req)
 
 	assert.Equal(t, "echo 'Tickling Fred'", out)
-	assert.Nil(t, err)
+	require.NoError(t, err)
 }
 
 func TestArgumentNotProvided(t *testing.T) {
@@ -356,8 +357,8 @@ func TestArgumentNotProvided(t *testing.T) {
 
 	out, err := parseActionArguments(req)
 
-	assert.Equal(t, "", out)
-	assert.Equal(t, err.Error(), "required arg not provided: personName")
+	assert.Empty(t, out)
+	require.EqualError(t, err, "required arg not provided: personName")
 }
 
 func TestExecArrayParsing(t *testing.T) {
@@ -372,7 +373,7 @@ func TestExecArrayParsing(t *testing.T) {
 
 	out, err := parseActionExec(req.Arguments, req.Binding.Action, req.Binding.Entity)
 
-	assert.Nil(t, err)
+	require.NoError(t, err)
 	assert.Equal(t, []string{"ls", "-alh"}, out)
 }
 
@@ -394,7 +395,7 @@ func TestExecArrayWithTemplateReplacement(t *testing.T) {
 
 	out, err := parseActionExec(values, &a1, nil)
 
-	assert.Nil(t, err)
+	require.NoError(t, err)
 	assert.Equal(t, []string{"ls", "-alh", "tmp"}, out)
 }
 
@@ -411,7 +412,7 @@ func TestCheckShellArgumentSafetyWithURL(t *testing.T) {
 	}
 
 	err := checkShellArgumentSafety(&a1)
-	assert.NotNil(t, err)
+	require.Error(t, err)
 	assert.Contains(t, err.Error(), "unsafe argument type 'url' cannot be used with Shell execution")
 	assert.Contains(t, err.Error(), "https://docs.olivetin.app/action_execution/shellvsexec.html")
 }
@@ -429,7 +430,7 @@ func TestCheckShellArgumentSafetyWithEmail(t *testing.T) {
 	}
 
 	err := checkShellArgumentSafety(&a1)
-	assert.NotNil(t, err)
+	require.Error(t, err)
 	assert.Contains(t, err.Error(), "unsafe argument type 'email' cannot be used with Shell execution")
 }
 
@@ -446,7 +447,7 @@ func TestCheckShellArgumentSafetyWithExec(t *testing.T) {
 	}
 
 	err := checkShellArgumentSafety(&a1)
-	assert.Nil(t, err)
+	require.NoError(t, err)
 }
 
 func TestCheckShellArgumentSafetyWithSafeTypes(t *testing.T) {
@@ -462,7 +463,7 @@ func TestCheckShellArgumentSafetyWithSafeTypes(t *testing.T) {
 	}
 
 	err := checkShellArgumentSafety(&a1)
-	assert.Nil(t, err)
+	require.NoError(t, err)
 }
 
 func TestCheckShellArgumentSafetyWithPassword(t *testing.T) {
@@ -478,7 +479,7 @@ func TestCheckShellArgumentSafetyWithPassword(t *testing.T) {
 	}
 
 	err := checkShellArgumentSafety(&a1)
-	assert.NotNil(t, err)
+	require.Error(t, err)
 	assert.Contains(t, err.Error(), "unsafe argument type 'password' cannot be used with Shell execution")
 	assert.Contains(t, err.Error(), "https://docs.olivetin.app/action_execution/shellvsexec.html")
 }
@@ -496,7 +497,7 @@ func TestCheckShellArgumentSafetyWithPasswordAndExec(t *testing.T) {
 	}
 
 	err := checkShellArgumentSafety(&a1)
-	assert.Nil(t, err)
+	require.NoError(t, err)
 }
 
 func TestCheckShellArgumentSafetyWithHTML(t *testing.T) {
@@ -509,7 +510,7 @@ func TestCheckShellArgumentSafetyWithHTML(t *testing.T) {
 	}
 
 	err := checkShellArgumentSafety(&a1)
-	assert.NotNil(t, err)
+	require.Error(t, err)
 	assert.Contains(t, err.Error(), "unsafe argument type 'html'")
 }
 
@@ -523,7 +524,7 @@ func TestCheckShellArgumentSafetyWithConfirmation(t *testing.T) {
 	}
 
 	err := checkShellArgumentSafety(&a1)
-	assert.Nil(t, err, "confirmation is constrained to 0/1 and is safe with shell")
+	require.NoError(t, err, "confirmation is constrained to 0/1 and is safe with shell")
 }
 
 func TestCheckShellArgumentSafetyWithUnnamedConfirmation(t *testing.T) {
@@ -536,7 +537,7 @@ func TestCheckShellArgumentSafetyWithUnnamedConfirmation(t *testing.T) {
 	}
 
 	err := checkShellArgumentSafety(&a1)
-	assert.Nil(t, err)
+	require.NoError(t, err)
 }
 
 func TestCheckShellArgumentSafetyWithChoicelessCheckbox(t *testing.T) {
@@ -549,7 +550,7 @@ func TestCheckShellArgumentSafetyWithChoicelessCheckbox(t *testing.T) {
 	}
 
 	err := checkShellArgumentSafety(&a1)
-	assert.NotNil(t, err)
+	require.Error(t, err)
 	assert.Contains(t, err.Error(), "unsafe argument type 'checkbox'")
 }
 
@@ -563,20 +564,20 @@ func TestCheckShellArgumentSafetyWithCustomRegex(t *testing.T) {
 	}
 
 	err := checkShellArgumentSafety(&a1)
-	assert.NotNil(t, err)
+	require.Error(t, err)
 	assert.Contains(t, err.Error(), "unsafe argument type 'regex:[a-zA-Z0-9.-]+'")
 }
 
 func TestTypeSafetyCheckUrl(t *testing.T) {
-	assert.Nil(t, TypeSafetyCheck("test1", "http://google.com", "url"), "Test URL: google.com")
-	assert.Nil(t, TypeSafetyCheck("test2", "http://technowax.net:80?foo=bar", "url"), "Test URL: technowax.net with query arguments")
-	assert.Nil(t, TypeSafetyCheck("test3", "http://localhost:80?foo=bar", "url"), "Test URL: localhost with query arguments")
-	assert.Nil(t, TypeSafetyCheck("test7", "https://example.com/path", "url"), "Test URL: https scheme")
-	assert.NotNil(t, TypeSafetyCheck("test4", "http://lo  host:80", "url"), "Test a badly formed URL")
-	assert.NotNil(t, TypeSafetyCheck("test5", "12345", "url"), "Test a badly formed URL")
-	assert.NotNil(t, TypeSafetyCheck("test6", "_!23;", "url"), "Test a badly formed URL")
-	assert.NotNil(t, TypeSafetyCheck("test8", "file:///etc/passwd", "url"), "file:// scheme must be rejected")
-	assert.NotNil(t, TypeSafetyCheck("test9", "gopher://example.com", "url"), "gopher:// scheme must be rejected")
+	require.NoError(t, TypeSafetyCheck("test1", "http://google.com", "url"), "Test URL: google.com")
+	require.NoError(t, TypeSafetyCheck("test2", "http://technowax.net:80?foo=bar", "url"), "Test URL: technowax.net with query arguments")
+	require.NoError(t, TypeSafetyCheck("test3", "http://localhost:80?foo=bar", "url"), "Test URL: localhost with query arguments")
+	require.NoError(t, TypeSafetyCheck("test7", "https://example.com/path", "url"), "Test URL: https scheme")
+	require.Error(t, TypeSafetyCheck("test4", "http://lo  host:80", "url"), "Test a badly formed URL")
+	require.Error(t, TypeSafetyCheck("test5", "12345", "url"), "Test a badly formed URL")
+	require.Error(t, TypeSafetyCheck("test6", "_!23;", "url"), "Test a badly formed URL")
+	require.Error(t, TypeSafetyCheck("test8", "file:///etc/passwd", "url"), "file:// scheme must be rejected")
+	require.Error(t, TypeSafetyCheck("test9", "gopher://example.com", "url"), "gopher:// scheme must be rejected")
 }
 
 func TestTypeSafetyCheckRegex(t *testing.T) {
@@ -622,9 +623,9 @@ func TestTypeSafetyCheckRegex(t *testing.T) {
 			err := typeSafetyCheckRegex(tt.field, tt.value, tt.pattern)
 
 			if tt.hasError {
-				assert.NotNil(t, err, "Expected error for value %s with pattern %s, but got no error", tt.value, tt.pattern)
+				require.Error(t, err, "Expected error for value %s with pattern %s, but got no error", tt.value, tt.pattern)
 			} else {
-				assert.Nil(t, err, "Expected no error for value %s with pattern %s, but got error: %v", tt.value, tt.pattern, err)
+				require.NoError(t, err, "Expected no error for value %s with pattern %s, but got error: %v", tt.value, tt.pattern, err)
 			}
 		})
 	}
@@ -687,9 +688,9 @@ func TestTypeSafetyCheckEmail(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			err := TypeSafetyCheck(tt.field, tt.value, "email")
 			if tt.hasError {
-				assert.NotNil(t, err, "Expected error for value '%s'", tt.value)
+				require.Error(t, err, "Expected error for value '%s'", tt.value)
 			} else {
-				assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
+				require.NoError(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
 			}
 		})
 	}
@@ -716,9 +717,9 @@ func TestTypeSafetyCheckDatetime(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			err := TypeSafetyCheck(tt.field, tt.value, "datetime")
 			if tt.hasError {
-				assert.NotNil(t, err, "Expected error for value '%s'", tt.value)
+				require.Error(t, err, "Expected error for value '%s'", tt.value)
 			} else {
-				assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
+				require.NoError(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
 			}
 		})
 	}
@@ -740,7 +741,7 @@ func TestTypeSafetyCheckRawStringMultiline(t *testing.T) {
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			err := TypeSafetyCheck(tt.field, tt.value, "raw_string_multiline")
-			assert.Nil(t, err, "raw_string_multiline should accept any value")
+			require.NoError(t, err, "raw_string_multiline should accept any value")
 		})
 	}
 }
@@ -772,6 +773,8 @@ func TestTypeSafetyCheckUnicodeIdentifier(t *testing.T) {
 }
 
 func validateTypeSafetyResult(t *testing.T, value string, expectsError bool, err error) {
+	t.Helper()
+
 	if expectsError {
 		assertErrorExpected(t, value, err)
 	} else {
@@ -780,6 +783,8 @@ func validateTypeSafetyResult(t *testing.T, value string, expectsError bool, err
 }
 
 func assertErrorExpected(t *testing.T, value string, err error) {
+	t.Helper()
+
 	if err == nil {
 		t.Errorf("Expected error for value '%s', but got none", value)
 	} else {
@@ -788,6 +793,8 @@ func assertErrorExpected(t *testing.T, value string, err error) {
 }
 
 func assertNoErrorExpected(t *testing.T, value string, err error) {
+	t.Helper()
+
 	if err != nil {
 		t.Errorf("Expected no error for value '%s', but got: %v", value, err)
 	} else {
@@ -816,9 +823,9 @@ func TestTypeSafetyCheckAsciiIdentifier(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			err := TypeSafetyCheck(tt.field, tt.value, "ascii_identifier")
 			if tt.hasError {
-				assert.NotNil(t, err, "Expected error for value '%s'", tt.value)
+				require.Error(t, err, "Expected error for value '%s'", tt.value)
 			} else {
-				assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
+				require.NoError(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
 			}
 		})
 	}
@@ -854,9 +861,9 @@ func TestTypeSafetyCheckDnsName(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			err := TypeSafetyCheck("host", tt.value, "dnsname")
 			if tt.hasError {
-				assert.NotNil(t, err, "Expected error for value '%s'", tt.value)
+				require.Error(t, err, "Expected error for value '%s'", tt.value)
 			} else {
-				assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
+				require.NoError(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
 			}
 		})
 	}
@@ -886,9 +893,9 @@ func TestTypeSafetyCheckShellSafeIdentifier(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			err := TypeSafetyCheck("username", tt.value, "shell_safe_identifier")
 			if tt.hasError {
-				assert.NotNil(t, err, "Expected error for value '%s'", tt.value)
+				require.Error(t, err, "Expected error for value '%s'", tt.value)
 			} else {
-				assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
+				require.NoError(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
 			}
 		})
 	}
@@ -915,9 +922,9 @@ func TestTypeSafetyCheckAsciiSentence(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			err := TypeSafetyCheck(tt.field, tt.value, "ascii_sentence")
 			if tt.hasError {
-				assert.NotNil(t, err, "Expected error for value '%s'", tt.value)
+				require.Error(t, err, "Expected error for value '%s'", tt.value)
 			} else {
-				assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
+				require.NoError(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
 			}
 		})
 	}
@@ -928,10 +935,9 @@ func TestTypecheckActionArgumentEmptyName(t *testing.T) {
 		Name: "",
 		Type: "ascii",
 	}
-	action := config.Action{Title: "Test"}
 
-	err := typecheckActionArgument(&arg, "test", &action)
-	assert.NotNil(t, err)
+	err := typecheckActionArgument(&arg, "test")
+	require.Error(t, err)
 	assert.Contains(t, err.Error(), "argument name cannot be empty")
 }
 
@@ -940,17 +946,16 @@ func TestTypecheckActionArgumentConfirmation(t *testing.T) {
 		Name: "confirm",
 		Type: "confirmation",
 	}
-	action := config.Action{Title: "Test"}
 
-	assert.Nil(t, typecheckActionArgument(&arg, "0", &action))
-	assert.Nil(t, typecheckActionArgument(&arg, "1", &action))
+	require.NoError(t, typecheckActionArgument(&arg, "0"))
+	require.NoError(t, typecheckActionArgument(&arg, "1"))
 
-	err := typecheckActionArgument(&arg, "any_value", &action)
-	assert.NotNil(t, err)
+	err := typecheckActionArgument(&arg, "any_value")
+	require.Error(t, err)
 	assert.Contains(t, err.Error(), "must be \"0\" or \"1\"")
 
-	err = typecheckActionArgument(&arg, "", &action)
-	assert.NotNil(t, err)
+	err = typecheckActionArgument(&arg, "")
+	require.Error(t, err)
 	assert.Contains(t, err.Error(), "must be \"0\" or \"1\"")
 }
 
@@ -959,10 +964,9 @@ func TestTypecheckActionArgumentUnnamedConfirmation(t *testing.T) {
 		Type:  "confirmation",
 		Title: "Are you sure?!",
 	}
-	action := config.Action{Title: "Test"}
 
-	assert.Nil(t, typecheckActionArgument(&arg, "", &action))
-	assert.Nil(t, typecheckActionArgument(&arg, "ignored", &action))
+	require.NoError(t, typecheckActionArgument(&arg, ""))
+	require.NoError(t, typecheckActionArgument(&arg, "ignored"))
 }
 
 func TestTypecheckActionArgumentHtmlWithoutName(t *testing.T) {
@@ -976,17 +980,17 @@ func TestTypecheckActionArgumentHtmlWithoutName(t *testing.T) {
 	}
 
 	err := validateArguments(map[string]string{}, &action)
-	assert.NoError(t, err)
+	require.NoError(t, err)
 }
 
 func TestParseCommandForReplacements(t *testing.T) {
 	tests := []struct {
+		values         map[string]string
 		name           string
 		shellCommand   string
-		values         map[string]string
 		expectedOutput string
-		expectError    bool
 		errorContains  string
+		expectError    bool
 	}{
 		{
 			name:           "Simple replacement",
@@ -1038,12 +1042,12 @@ func TestParseCommandForReplacements(t *testing.T) {
 			output, err := tpl.ParseTemplateWithActionContext(tt.shellCommand, nil, tt.values)
 
 			if tt.expectError {
-				assert.NotNil(t, err, "Expected error but got none")
+				require.Error(t, err, "Expected error but got none")
 				if tt.errorContains != "" {
 					assert.Contains(t, err.Error(), tt.errorContains)
 				}
 			} else {
-				assert.Nil(t, err, "Expected no error but got: %v", err)
+				require.NoError(t, err, "Expected no error but got: %v", err)
 				assert.Equal(t, tt.expectedOutput, output)
 			}
 		})
@@ -1052,10 +1056,10 @@ func TestParseCommandForReplacements(t *testing.T) {
 
 func TestArgumentChoicesValidation(t *testing.T) {
 	tests := []struct {
-		name        string
 		req         *ExecutionRequest
-		expectError bool
+		name        string
 		description string
+		expectError bool
 	}{
 		{
 			name: "Valid choice",
@@ -1136,10 +1140,10 @@ func TestArgumentChoicesValidation(t *testing.T) {
 			_, err := parseActionArguments(tt.req)
 
 			if tt.expectError {
-				assert.NotNil(t, err, tt.description)
+				require.Error(t, err, tt.description)
 				assert.Contains(t, err.Error(), "predefined choices")
 			} else {
-				assert.Nil(t, err, tt.description)
+				require.NoError(t, err, tt.description)
 			}
 		})
 	}
@@ -1161,7 +1165,7 @@ func TestTypeSafetyCheckVeryDangerousRawString(t *testing.T) {
 	for _, value := range tests {
 		t.Run(fmt.Sprintf("Value: %s", value), func(t *testing.T) {
 			err := TypeSafetyCheck("test", value, "very_dangerous_raw_string")
-			assert.Nil(t, err, "very_dangerous_raw_string should accept any value including: %s", value)
+			require.NoError(t, err, "very_dangerous_raw_string should accept any value including: %s", value)
 		})
 	}
 }
@@ -1186,7 +1190,7 @@ func TestParseActionArgumentsWithEntityPrefix(t *testing.T) {
 
 	// Test with entity prefix
 	output, err := parseActionArguments(req)
-	assert.Nil(t, err)
+	require.NoError(t, err)
 	assert.Contains(t, output, "testuser")
 }
 
@@ -1227,9 +1231,9 @@ func TestComplexRegexPatterns(t *testing.T) {
 		t.Run(tt.name, func(t *testing.T) {
 			err := typeSafetyCheckRegex("test", tt.value, tt.pattern)
 			if tt.hasError {
-				assert.NotNil(t, err)
+				require.Error(t, err)
 			} else {
-				assert.Nil(t, err)
+				require.NoError(t, err)
 			}
 		})
 	}

+ 131 - 76
service/internal/executor/executor.go

@@ -15,11 +15,14 @@ import (
 
 	"bytes"
 	"context"
+	"errors"
 	"fmt"
+	"maps"
 	"os"
 	"os/exec"
 	"path"
 	"regexp"
+	"slices"
 	"strings"
 	"sync"
 	"time"
@@ -39,53 +42,44 @@ func isValidTrackingID(id string) bool {
 }
 
 type ActionBinding struct {
-	ID           string
 	Action       *config.Action
 	Entity       *entities.Entity
-	ConfigOrder  int
+	ID           string
 	OnDashboards []DashboardNavigationTarget
+	ConfigOrder  int
 }
 
-// Executor represents a helper class for executing commands. It's main method
-// is ExecRequest
 type Executor struct {
 	logs                  map[string]*InternalLogEntry
-	logsTrackingIdsByDate []string
 	LogsByBindingId       map[string][]*InternalLogEntry
-
-	logmutex sync.RWMutex
-
 	MapActionBindings     map[string]*ActionBinding
+	Cfg                   *config.Config
+	logsTrackingIdsByDate []string
+	listeners             []listener
+	chainOfCommand        []executorStepFunc
+	groupQueue            []*queuedExecution
+	logmutex              sync.RWMutex
 	MapActionBindingsLock sync.RWMutex
-
-	Cfg *config.Config
-
-	listeners   []listener
-	listenersMu sync.RWMutex
-
-	chainOfCommand []executorStepFunc
-
-	groupQueue   []*queuedExecution
-	groupQueueMu sync.Mutex
+	listenersMu           sync.RWMutex
+	groupQueueMu          sync.Mutex
 }
 
 // ExecutionRequest is a request to execute an action. It's passed to an
 // Executor. They're created from the api.
 type ExecutionRequest struct {
-	Binding           *ActionBinding
-	Arguments         map[string]string
-	TrackingID        string
-	Tags              []string
-	Cfg               *config.Config
-	AuthenticatedUser *authpublic.AuthenticatedUser
-	TriggerDepth      int
-	Justification     string
-
+	Arguments               map[string]string
+	Binding                 *ActionBinding
+	Cfg                     *config.Config
+	AuthenticatedUser       *authpublic.AuthenticatedUser
+	executor                *Executor
 	logEntry                *InternalLogEntry
 	finalParsedCommand      string
+	TrackingID              string
+	Justification           string
+	Tags                    []string
 	execArgs                []string
+	TriggerDepth            int
 	useDirectExec           bool
-	executor                *Executor
 	skipRequestRegistration bool
 }
 
@@ -103,12 +97,12 @@ func (req *ExecutionRequest) mutateLogEntry(mutator func(*InternalLogEntry)) {
 
 // LogEntrySnapshot is a copy of selected log entry fields for race-safe reads.
 type LogEntrySnapshot struct {
+	Output            string
+	ExitCode          int32
 	Queued            bool
 	Blocked           bool
 	ExecutionStarted  bool
 	ExecutionFinished bool
-	ExitCode          int32
-	Output            string
 }
 
 // SnapshotLog returns a copy of selected log entry fields under read lock.
@@ -135,34 +129,28 @@ func (e *Executor) SnapshotLog(trackingID string) (LogEntrySnapshot, bool) {
 // state of execution (even if the command is not executed). It's designed to be
 // easily serializable.
 type InternalLogEntry struct {
-	Binding             *ActionBinding
 	DatetimeStarted     time.Time
 	DatetimeFinished    time.Time
-	Output              string
-	TimedOut            bool
-	Blocked             bool
-	Queued              bool
-	QueuedForGroup      string
-	ExitCode            int32
-	Tags                []string
-	ExecutionStarted    bool
-	ExecutionFinished   bool
-	ExecutionTrackingID string
+	Binding             *ActionBinding
 	Process             *os.Process
+	Arguments           map[string]string
+	ExecutionTrackingID string
+	Justification       string
+	QueuedForGroup      string
+	ActionIcon          string
+	ActionTitle         string
+	ActionConfigTitle   string
+	Output              string
 	Username            string
-	Index               int64
 	EntityPrefix        string
-	ActionConfigTitle   string // This is the title of the action as defined in the config, not the final parsed title.
-
-	/*
-		The following 3 properties are obviously on Action normally, but it's useful
-		that logs are lightweight (so we don't need to have an action associated to
-		logs, etc. Therefore, we duplicate those values here.
-	*/
-	ActionTitle   string
-	ActionIcon    string
-	Justification string
-	Arguments     map[string]string
+	Tags                []string
+	Index               int64
+	ExitCode            int32
+	Blocked             bool
+	ExecutionFinished   bool
+	ExecutionStarted    bool
+	Queued              bool
+	TimedOut            bool
 }
 
 // .Binding can be nil, so we need to handle that.
@@ -939,12 +927,7 @@ func keepArgument(name string, definedNames map[string]struct{}) bool {
 }
 
 func hasWebhookTag(req *ExecutionRequest) bool {
-	for _, tag := range req.Tags {
-		if tag == "webhook" {
-			return true
-		}
-	}
-	return false
+	return slices.Contains(req.Tags, "webhook")
 }
 
 var systemArgumentDefinitions = []config.ActionArgument{
@@ -958,9 +941,7 @@ func injectSystemArgs(req *ExecutionRequest) error {
 		return err
 	}
 
-	for name, value := range args {
-		req.Arguments[name] = value
-	}
+	maps.Copy(req.Arguments, args)
 
 	return nil
 }
@@ -1097,8 +1078,8 @@ func appendErrorToStderr(req *ExecutionRequest, err error) {
 
 type OutputStreamer struct {
 	Req    *ExecutionRequest
-	mu     sync.Mutex
 	output bytes.Buffer
+	mu     sync.Mutex
 }
 
 func (ost *OutputStreamer) Write(o []byte) (n int, err error) {
@@ -1186,7 +1167,7 @@ func stepExec(req *ExecutionRequest) bool {
 	appendErrorToStderr(req, runerr)
 	appendErrorToStderr(req, waiterr)
 
-	if ctx.Err() == context.DeadlineExceeded {
+	if errors.Is(ctx.Err(), context.DeadlineExceeded) {
 		log.WithFields(log.Fields{
 			"actionTitle": req.logEntry.ActionTitle,
 		}).Warnf("Action timed out")
@@ -1263,7 +1244,7 @@ func stepExecAfter(req *ExecutionRequest) bool {
 	appendErrorToStderr(req, runerr)
 	appendErrorToStderr(req, waiterr)
 
-	if ctx.Err() == context.DeadlineExceeded {
+	if errors.Is(ctx.Err(), context.DeadlineExceeded) {
 		req.mutateLogEntry(func(entry *InternalLogEntry) {
 			entry.Output += "Your shellAfterCompleted command timed out."
 		})
@@ -1290,21 +1271,95 @@ func shellAfterCompletedAction(req *ExecutionRequest) (*config.Action, bool) {
 	return req.Binding.Action, true
 }
 
+// Matches legacy and modern template forms for shellAfterCompleted output/exitCode,
+// including optional .Arguments. prefix and flexible whitespace. These must become
+// quoted env refs before template execution so command output cannot inject into sh -c.
+var (
+	shellAfterOutputRef   = regexp.MustCompile(`\{\{\s*(?:\.Arguments\.)?output\s*\}\}`)
+	shellAfterExitCodeRef = regexp.MustCompile(`\{\{\s*(?:\.Arguments\.)?exitCode\s*\}\}`)
+)
+
 func substituteShellAfterCompletedEnvRefs(command string) string {
-	replacements := []struct{ old, new string }{
-		{"{{ output }}", `"$OUTPUT"`},
-		{"{{output}}", `"$OUTPUT"`},
-		{"{{ exitCode }}", `"$EXITCODE"`},
-		{"{{exitCode}}", `"$EXITCODE"`},
-		{"{{ exitCode}}", `"$EXITCODE"`},
-		{"{{exitCode }}", `"$EXITCODE"`},
+	command = replaceShellAfterEnvRef(command, shellAfterOutputRef, "$OUTPUT")
+	command = replaceShellAfterEnvRef(command, shellAfterExitCodeRef, "$EXITCODE")
+	return command
+}
+
+func replaceShellAfterEnvRef(command string, pattern *regexp.Regexp, envRef string) string {
+	matches := pattern.FindAllStringIndex(command, -1)
+	for i := len(matches) - 1; i >= 0; i-- {
+		start, end := matches[i][0], matches[i][1]
+		replacement := `"` + envRef + `"`
+		if shellPosInsideSingleQuotes(command, start) {
+			// Break out of single quotes so the env ref can expand at runtime.
+			replacement = `'` + replacement + `'`
+		}
+		command = command[:start] + replacement + command[end:]
 	}
+	return command
+}
 
-	for _, replacement := range replacements {
-		command = strings.ReplaceAll(command, replacement.old, replacement.new)
+func shellPosInsideSingleQuotes(command string, pos int) bool {
+	inSingle := false
+	inDouble := false
+	i := 0
+
+	for i < pos {
+		inSingle, inDouble, i = advanceShellQuoteState(command, i, pos, inSingle, inDouble)
 	}
 
-	return command
+	return inSingle
+}
+
+func advanceShellQuoteState(command string, i, pos int, inSingle, inDouble bool) (bool, bool, int) {
+	if inSingle {
+		return advanceInsideSingleQuote(command, i, inSingle, inDouble)
+	}
+	if inDouble {
+		return advanceInsideDoubleQuote(command, i, pos, inSingle, inDouble)
+	}
+	return advanceOutsideQuotes(command, i, inSingle, inDouble)
+}
+
+func advanceInsideSingleQuote(command string, i int, inSingle, inDouble bool) (bool, bool, int) {
+	if command[i] == '\'' {
+		return false, inDouble, i + 1
+	}
+	return inSingle, inDouble, i + 1
+}
+
+func advanceInsideDoubleQuote(command string, i, pos int, inSingle, inDouble bool) (bool, bool, int) {
+	if command[i] == '\\' && i+1 < pos {
+		return inSingle, inDouble, i + 2
+	}
+	if command[i] == '"' {
+		return inSingle, false, i + 1
+	}
+	return inSingle, inDouble, i + 1
+}
+
+func advanceOutsideQuotes(command string, i int, inSingle, inDouble bool) (bool, bool, int) {
+	switch command[i] {
+	case '\'':
+		return true, inDouble, i + 1
+	case '"':
+		return inSingle, true, i + 1
+	default:
+		return inSingle, inDouble, i + 1
+	}
+}
+
+// shellAfterTemplateArgs omits output/exitCode so templates cannot expand them
+// raw. Those values are only provided as OUTPUT/EXITCODE process environment.
+func shellAfterTemplateArgs(args map[string]string) map[string]string {
+	templateArgs := make(map[string]string, len(args))
+	for name, value := range args {
+		if name == "output" || name == "exitCode" {
+			continue
+		}
+		templateArgs[name] = value
+	}
+	return templateArgs
 }
 
 func parseShellAfterCompletedCommand(req *ExecutionRequest, commandTemplate string, args map[string]string) (string, error) {
@@ -1338,7 +1393,7 @@ func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout,
 	}
 
 	commandTemplate := substituteShellAfterCompletedEnvRefs(action.ShellAfterCompleted)
-	finalParsedCommand, err := parseShellAfterCompletedCommand(req, commandTemplate, args)
+	finalParsedCommand, err := parseShellAfterCompletedCommand(req, commandTemplate, shellAfterTemplateArgs(args))
 	if err != nil {
 		return nil, nil, err
 	}

+ 127 - 0
service/internal/executor/executor_test.go

@@ -449,6 +449,133 @@ func TestShellAfterCompletedUsesOutputEnvSafely(t *testing.T) {
 	assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands from output")
 }
 
+func TestShellAfterCompletedExpandsQuotedPlaceholders(t *testing.T) {
+	cases := []struct {
+		name string
+		sac  string
+	}{
+		{"legacy single-quoted", `printf '%s' '{{ output }}'`},
+		{"modern single-quoted", `printf '%s' '{{ .Arguments.output }}'`},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			cfg := config.DefaultConfig()
+			executor := DefaultExecutor(cfg)
+			mainOutput := "quoted-output-ok"
+
+			action := &config.Action{
+				Title:               "sac-quoted-" + tc.name,
+				Shell:               "printf %s \"" + mainOutput + "\"",
+				ShellAfterCompleted: tc.sac,
+			}
+			cfg.Actions = append(cfg.Actions, action)
+			cfg.Sanitize()
+			executor.RebuildActionMap()
+
+			req := ExecutionRequest{
+				AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
+				Cfg:               cfg,
+				Binding:           executor.FindBindingWithNoEntity(action),
+			}
+			wg, _ := executor.ExecRequest(&req)
+			wg.Wait()
+
+			require.NotNil(t, req.logEntry)
+			assert.Equal(t, int32(0), req.logEntry.ExitCode)
+			assert.Contains(t, req.logEntry.Output, "OliveTin::shellAfterCompleted stdout\n"+mainOutput)
+		})
+	}
+}
+
+func TestShellAfterCompletedBlocksArgumentsOutputInjection(t *testing.T) {
+	payload := func(injectedPath string) string {
+		return "x; touch " + injectedPath + "; #"
+	}
+
+	cases := []struct {
+		name string
+		sac  string
+	}{
+		{"legacy", "printf %s {{ output }}"},
+		{"legacy compact", "printf %s {{output}}"},
+		{"legacy extra spaces", "printf %s {{  output  }}"},
+		{"modern Arguments", "printf %s {{ .Arguments.output }}"},
+		{"modern compact", "printf %s {{.Arguments.output}}"},
+		{"modern exitCode still env", "printf %s {{ .Arguments.exitCode }}"},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			cfg := config.DefaultConfig()
+			executor := DefaultExecutor(cfg)
+			injectedPath := filepath.Join(t.TempDir(), "injected")
+			mainPayload := payload(injectedPath)
+
+			action := &config.Action{
+				Title:               "sac-injection-" + tc.name,
+				Shell:               "printf %s \"" + mainPayload + "\"",
+				ShellAfterCompleted: tc.sac,
+			}
+			cfg.Actions = append(cfg.Actions, action)
+			cfg.Sanitize()
+			executor.RebuildActionMap()
+
+			req := ExecutionRequest{
+				AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
+				Cfg:               cfg,
+				Binding:           executor.FindBindingWithNoEntity(action),
+			}
+			wg, _ := executor.ExecRequest(&req)
+			wg.Wait()
+
+			_, err := os.Stat(injectedPath)
+			assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands via %q", tc.sac)
+		})
+	}
+}
+
+func TestSubstituteShellAfterCompletedEnvRefs(t *testing.T) {
+	cases := []struct {
+		in   string
+		want string
+	}{
+		{`printf %s {{ output }}`, `printf %s "$OUTPUT"`},
+		{`printf %s {{output}}`, `printf %s "$OUTPUT"`},
+		{`printf %s {{  output  }}`, `printf %s "$OUTPUT"`},
+		{`printf %s {{ .Arguments.output }}`, `printf %s "$OUTPUT"`},
+		{`printf %s {{.Arguments.output}}`, `printf %s "$OUTPUT"`},
+		{`echo {{ exitCode }}`, `echo "$EXITCODE"`},
+		{`echo {{ .Arguments.exitCode }}`, `echo "$EXITCODE"`},
+		{`echo {{  .Arguments.exitCode  }}`, `echo "$EXITCODE"`},
+		{`printf '%s' '{{ output }}'`, `printf '%s' ''"$OUTPUT"''`},
+		{`printf '%s' '{{ .Arguments.output }}'`, `printf '%s' ''"$OUTPUT"''`},
+		{`printf '%s' '{{ exitCode }}'`, `printf '%s' ''"$EXITCODE"''`},
+		{`printf '%s' '{{ .Arguments.exitCode }}'`, `printf '%s' ''"$EXITCODE"''`},
+	}
+
+	for _, tc := range cases {
+		assert.Equal(t, tc.want, substituteShellAfterCompletedEnvRefs(tc.in))
+	}
+}
+
+func TestShellAfterTemplateArgsOmitsOutputAndExitCode(t *testing.T) {
+	args := map[string]string{
+		"output":                 "evil; id",
+		"exitCode":               "1",
+		"ot_username":            "alice",
+		"ot_executionTrackingId": "track-1",
+	}
+
+	templateArgs := shellAfterTemplateArgs(args)
+
+	assert.NotContains(t, templateArgs, "output")
+	assert.NotContains(t, templateArgs, "exitCode")
+	assert.Equal(t, "alice", templateArgs["ot_username"])
+	assert.Equal(t, "track-1", templateArgs["ot_executionTrackingId"])
+	assert.Equal(t, "evil; id", args["output"], "env args map must keep output for OUTPUT=")
+}
+
 func TestFilterToDefinedArgumentsOnly(t *testing.T) {
 	req := newExecRequest()
 	req.Binding.Action = &config.Action{

+ 0 - 1
service/internal/executor/executor_unix.go

@@ -1,5 +1,4 @@
 //go:build !windows
-// +build !windows
 
 package executor
 

+ 2 - 1
service/internal/executor/timeout_context.go

@@ -2,6 +2,7 @@ package executor
 
 import (
 	"context"
+	"errors"
 	"os"
 	"sync"
 	"time"
@@ -55,7 +56,7 @@ func (tc *timeoutContext) setProcess(process *os.Process) {
 	tc.processMu.Unlock()
 
 	// If deadline already expired before process was set, kill now
-	if tc.Err() == context.DeadlineExceeded && process != nil {
+	if errors.Is(tc.Err(), context.DeadlineExceeded) && process != nil {
 		logEntry := &InternalLogEntry{Process: process}
 		if err := tc.executor.Kill(logEntry); err != nil {
 			log.WithFields(log.Fields{

+ 3 - 3
service/internal/filehelper/file_change_notify.go

@@ -39,12 +39,12 @@ type WatchMeta struct {
 }
 
 type watchContext struct {
-	filename        string
-	filedir         string
 	callback        func(filename string)
-	interestedEvent fsnotify.Op
 	event           *fsnotify.Event
 	meta            WatchMeta
+	filename        string
+	filedir         string
+	interestedEvent fsnotify.Op
 }
 
 func WatchDirectoryCreate(fullpath string, callback func(filename string), meta WatchMeta) {

+ 7 - 2
service/internal/httpservers/frontend.go

@@ -14,6 +14,7 @@ import (
 	"net/url"
 	"path"
 	"strings"
+	"time"
 
 	"github.com/OliveTin/OliveTin/internal/api"
 	"github.com/OliveTin/OliveTin/internal/auth"
@@ -153,8 +154,12 @@ func StartFrontendMux(cfg *config.Config, ex *executor.Executor) {
 	}
 
 	srv := &http.Server{
-		Addr:    cfg.ListenAddressSingleHTTPFrontend,
-		Handler: securityHeadersMiddleware(cfg, mux),
+		Addr:              cfg.ListenAddressSingleHTTPFrontend,
+		Handler:           securityHeadersMiddleware(cfg, mux),
+		ReadHeaderTimeout: 10 * time.Second,
+		ReadTimeout:       30 * time.Second,
+		IdleTimeout:       120 * time.Second,
+		// WriteTimeout intentionally unset: EventStream and StartActionAndWait need long-lived writes.
 	}
 
 	log.Fatal(srv.ListenAndServe())

+ 14 - 2
service/internal/httpservers/prometheus.go

@@ -2,6 +2,7 @@ package httpservers
 
 import (
 	"net/http"
+	"time"
 
 	config "github.com/OliveTin/OliveTin/internal/config"
 	"github.com/prometheus/client_golang/prometheus"
@@ -19,8 +20,19 @@ func StartPrometheus(cfg *config.Config) {
 		prometheus.Unregister(collectors.NewGoCollector())
 	}
 
-	http.Handle("/", promhttp.Handler())
-	err := http.ListenAndServe(cfg.ListenAddressPrometheus, nil)
+	mux := http.NewServeMux()
+	mux.Handle("/", promhttp.Handler())
+
+	srv := &http.Server{
+		Addr:              cfg.ListenAddressPrometheus,
+		Handler:           mux,
+		ReadHeaderTimeout: 10 * time.Second,
+		ReadTimeout:       30 * time.Second,
+		WriteTimeout:      30 * time.Second,
+		IdleTimeout:       120 * time.Second,
+	}
+
+	err := srv.ListenAndServe()
 
 	if err != nil {
 		log.WithFields(log.Fields{

+ 6 - 8
service/internal/httpservers/webuiServer.go

@@ -35,18 +35,16 @@ func NewWebUIServer(cfg *config.Config) *webUIServer {
 }
 
 func (s *webUIServer) handleWebui(w http.ResponseWriter, r *http.Request) {
-	// dirName := path.Dir(r.URL.Path)
-
 	// Mangle requests for any path like /logs or /config to load the webui index.html
 	if path.Ext(r.URL.Path) == "" && r.URL.Path != "/" {
 		log.Debugf("Mangling request for %s to /index.html", r.URL.Path)
-
 		http.ServeFile(w, r, path.Join(s.webuiDir, "index.html"))
-	} else {
-		log.Tracef("Serving webui from %s for %s", s.webuiDir, r.URL.Path)
-		http.ServeFile(w, r, path.Join(s.webuiDir, r.URL.Path))
-		//		http.StripPrefix(dirName, http.FileServer(http.Dir(s.webuiDir))).ServeHTTP(w, r)
+		return
 	}
+
+	log.Tracef("Serving webui from %s for %s", s.webuiDir, r.URL.Path)
+	// http.Dir rejects path traversal; do not Join raw URL paths into ServeFile.
+	http.FileServer(http.Dir(s.webuiDir)).ServeHTTP(w, r)
 }
 
 func (s *webUIServer) findWebuiDir() string {
@@ -84,7 +82,7 @@ func (s *webUIServer) findCustomWebuiDir() string {
 func (s *webUIServer) setupCustomWebuiDir() {
 	dir := s.findCustomWebuiDir()
 
-	err := os.MkdirAll(path.Join(dir, "themes/"), 0775)
+	err := os.MkdirAll(path.Join(dir, "themes/"), 0o750)
 
 	if err != nil {
 		log.Warnf("Could not create themes directory: %v", err)

+ 1 - 1
service/internal/installationinfo/runtimeinfo.go

@@ -16,7 +16,6 @@ type RuntimeInfo struct {
 	OS                   string
 	OSReleasePrettyName  string
 	Arch                 string
-	InContainer          bool
 	LastBrowserUserAgent string
 	User                 string
 	Uid                  string
@@ -25,6 +24,7 @@ type RuntimeInfo struct {
 	AvailableVersion     string
 	WebuiDirectory       string
 	ThemesDirectory      string
+	InContainer          bool
 }
 
 var Runtime = &RuntimeInfo{

+ 2 - 2
service/internal/installationinfo/server_diagnostics.go

@@ -13,8 +13,6 @@ var (
 )
 
 type serverDiagnosticsConfig struct {
-	CountOfActions                  int
-	CountOfDashboards               int
 	LogLevel                        string
 	ListenAddressSingleHTTPFrontend string
 	ListenAddressWebUI              string
@@ -23,6 +21,8 @@ type serverDiagnosticsConfig struct {
 	TimeNow                         string
 	ConfigDirectory                 string
 	WebuiDirectory                  string
+	CountOfActions                  int
+	CountOfDashboards               int
 }
 
 func configToServerDiagnostics(cfg *config.Config) *serverDiagnosticsConfig {

+ 2 - 2
service/internal/logfilter/record.go

@@ -5,12 +5,12 @@ type Record struct {
 	Status   string
 	Action   string
 	User     string
+	Output   string
 	Tags     []string
+	ExitCode int32
 	Blocked  bool
 	TimedOut bool
 	Running  bool
-	ExitCode int32
-	Output   string
 }
 
 // StatusLabel matches the status text shown in the web UI.

+ 1 - 1
service/internal/servicehost/log_directory_test.go

@@ -13,7 +13,7 @@ func TestResolveLogDirectory(t *testing.T) {
 	baseDir := filepath.Join(t.TempDir(), "OliveTin")
 	absoluteDir := t.TempDir()
 
-	assert.Equal(t, "", resolveLogDirectory("", baseDir))
+	assert.Empty(t, resolveLogDirectory("", baseDir))
 	assert.Equal(t, absoluteDir, resolveLogDirectory(absoluteDir, baseDir))
 	assert.Equal(t, filepath.Join(baseDir, "logs", "service"), resolveLogDirectory("./logs/service", baseDir))
 	assert.Equal(t, "logs/service", resolveLogDirectory("logs/service", ""))

+ 0 - 1
service/internal/servicehost/servicehost_nonwin.go

@@ -1,5 +1,4 @@
 //go:build !windows
-// +build !windows
 
 package servicehost
 

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

@@ -44,7 +44,7 @@ type generalTemplateContext struct {
 }
 
 type actionTemplateContext struct {
-	CurrentEntity interface{}
+	CurrentEntity any
 	Arguments     map[string]string
 
 	// These are deliberately repeated because embedding structs

+ 9 - 3
service/internal/updatecheck/updateCheck.go

@@ -1,6 +1,7 @@
 package updatecheck
 
 import (
+	"context"
 	"encoding/json"
 	"github.com/Masterminds/semver"
 	config "github.com/OliveTin/OliveTin/internal/config"
@@ -10,12 +11,13 @@ import (
 	"io"
 	"net/http"
 	"os"
+	"time"
 )
 
 type versionMapType struct {
-	ApiVersion int
-	Latest     string
 	History    map[string]string
+	Latest     string
+	ApiVersion int
 }
 
 // StartUpdateChecker will start a job that runs periodically, checking
@@ -84,7 +86,11 @@ func parseIfVersionIsLater(currentString string, latestString string) string {
 }
 
 func doRequest() string {
-	req, err := http.NewRequest("GET", "http://update-check.olivetin.app/versions.json", nil)
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+
+	defer cancel()
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://update-check.olivetin.app/versions.json", nil)
 
 	if err != nil {
 		log.Errorf("Update check failed %v", err)

+ 3 - 3
service/internal/webhooks/jsonpath.go

@@ -8,11 +8,11 @@ import (
 )
 
 type JSONMatcher struct {
-	payload interface{}
+	payload any
 }
 
 func NewJSONMatcher(payload []byte) (*JSONMatcher, error) {
-	var data interface{}
+	var data any
 	if err := json.Unmarshal(payload, &data); err != nil {
 		return nil, err
 	}
@@ -60,6 +60,6 @@ func (m *JSONMatcher) ExtractValue(pathExpr string) (string, error) {
 	return string(jsonBytes), nil
 }
 
-func (m *JSONMatcher) GetPayload() interface{} {
+func (m *JSONMatcher) GetPayload() any {
 	return m.payload
 }

+ 1 - 2
service/internal/webhooks/matcher.go

@@ -123,8 +123,7 @@ func (m *WebhookMatcher) matchPathValue(matcher *JSONMatcher, jsonPath, expected
 }
 
 func (m *WebhookMatcher) compareValues(actual, expected string) bool {
-	if strings.HasPrefix(expected, "regex:") {
-		pattern := strings.TrimPrefix(expected, "regex:")
+	if pattern, hasRegex := strings.CutPrefix(expected, "regex:"); hasRegex {
 		matched, err := regexp.MatchString(pattern, actual)
 		if err != nil {
 			log.WithFields(log.Fields{

+ 2 - 2
service/internal/webhooks/matcher_justification_test.go

@@ -12,7 +12,7 @@ import (
 
 func TestExtractJustificationFromWebhookBody(t *testing.T) {
 	body := []byte(`{"message":"deploy production","repo":"my-app"}`)
-	req, err := http.NewRequest(http.MethodPost, "/webhooks/deploy", nil)
+	req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "/webhooks/deploy", nil)
 	require.NoError(t, err)
 
 	matcher := NewWebhookMatcher(config.WebhookConfig{
@@ -25,7 +25,7 @@ func TestExtractJustificationFromWebhookBody(t *testing.T) {
 }
 
 func TestExtractJustificationEmptyWhenNotConfigured(t *testing.T) {
-	req, err := http.NewRequest(http.MethodPost, "/webhooks/deploy", nil)
+	req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "/webhooks/deploy", nil)
 	require.NoError(t, err)
 
 	matcher := NewWebhookMatcher(config.WebhookConfig{}, req, []byte(`{}`))

+ 1 - 1
service/main.go

@@ -158,7 +158,7 @@ func configPathExists(configPath string) bool {
 }
 
 func watchConfigFile(k *koanf.Koanf, f *file.File, configPath string) {
-	err := f.Watch(func(evt interface{}, err error) {
+	err := f.Watch(func(evt any, err error) {
 		log.Infof("config file changed: %v", evt)
 
 		errLoad := k.Load(f, yaml.Parser())

+ 13 - 7
service/scripts/find-flakey-tests-inf/main.go

@@ -2,7 +2,9 @@ package main
 
 import (
 	"bufio"
+	"context"
 	"encoding/json"
+	"errors"
 	"fmt"
 	"io"
 	"os"
@@ -41,20 +43,20 @@ type runSummary struct {
 }
 
 type jsonlRecord struct {
-	Run            int           `json:"run"`
 	Timestamp      string        `json:"timestamp"`
+	FailureDetails []testFailure `json:"failureDetails"`
+	Run            int           `json:"run"`
 	ExitCode       int           `json:"exitCode"`
 	DurationMs     int64         `json:"durationMs"`
 	Passes         int           `json:"passes"`
 	Failures       int           `json:"failures"`
 	Skipped        int           `json:"skipped"`
-	FailureDetails []testFailure `json:"failureDetails"`
 }
 
 type testRunState struct {
-	summary       runSummary
-	failures      []testFailure
 	failureOutput map[string]*strings.Builder
+	failures      []testFailure
+	summary       runSummary
 }
 
 func initLog() {
@@ -312,9 +314,9 @@ func scanTestEvents(stdout io.Reader, state *testRunState) error {
 
 func finishTestCommand(cmd *exec.Cmd, state *testRunState) (int, runSummary, []testFailure, error) {
 	if err := cmd.Wait(); err != nil {
-		if exitErr, ok := err.(*exec.ExitError); ok {
+		if errExit, ok := errors.AsType[*exec.ExitError](err); ok {
 			state.finalizeFailureOutputs()
-			return exitErr.ExitCode(), state.summary, state.failures, nil
+			return errExit.ExitCode(), state.summary, state.failures, nil
 		}
 		return 1, state.summary, state.failures, err
 	}
@@ -322,7 +324,11 @@ func finishTestCommand(cmd *exec.Cmd, state *testRunState) (int, runSummary, []t
 }
 
 func runTestsOnce(rootDir string) (int, runSummary, []testFailure, error) {
-	cmd := exec.Command("go", "test", "./...", "-count=1", "-json")
+	ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
+
+	defer cancel()
+
+	cmd := exec.CommandContext(ctx, "go", "test", "./...", "-count=1", "-json")
 	cmd.Dir = rootDir
 
 	stdout, err := cmd.StdoutPipe()

+ 50 - 0
specs/config-issues.md

@@ -0,0 +1,50 @@
+# Configuration issues
+
+This spec describes how OliveTin collects configuration warnings and errors and surfaces them in the web UI.
+
+## Purpose
+
+Operators should see configuration problems in Diagnostics instead of only in server logs. When any issues exist, the Diagnostics navigation link shows a count badge.
+
+## When issues are rebuilt
+
+The issue list is cleared and rebuilt when configuration is loaded or reloaded, and when entity data changes. Some findings that can only be detected while configuration is first being loaded (for example references to unset environment variables that are expanded away during that load) are kept across later rebuilds until the next configuration load begins.
+
+## What is collected
+
+Issues include:
+
+- Unknown or unenforced action group references
+- Checklist arguments with missing or invalid choice templates
+- Arguments whose type was left unset (defaulted to a generic text type)
+- Unset environment variables referenced from configuration
+- Missing or invalid include directories
+- Argument default or choice templates that fail to parse
+- Literal argument defaults that fail type validation (templated defaults are not type-checked as raw text)
+- Entity files that cannot be read or parsed, or are empty
+- Entity-bound actions with no entity instances (after OliveTin has attempted to load that entity type, so startup does not report a false positive before entity files are read)
+- Invalid cron schedules
+- Entity-bound actions that also use scheduled cron execution (cron runs without an entity binding and will not execute)
+- Filesystem watch paths that cannot be created (missing directories for file-in-dir triggers, calendar files, or entity files). Runtime watcher setup failures for action triggers include the related action so view permissions still apply; entity-file watchers without an action remain visible to anyone who may view Diagnostics.
+
+Each issue has a severity of warning or error, a stable code, a human-readable message, and optional context such as action title, argument name, configuration source file, or detail value.
+
+When the issue list is rebuilt, OliveTin logs only newly appeared issues so startup does not repeat the same warning for every rebuild.
+
+When configuration is loaded from a base file and an include directory, OliveTin records which file defined each action and entity declaration. That path is shown as the configuration source file when available. Some issues (for example unset environment variables) may not have a specific file. Entity data file problems also show the entity data path in the detail column.
+
+## Diagnostics page
+
+Users who are allowed to view Diagnostics see a Configuration issues section listing the current issues in a table. When there are none, the section states that no configuration issues were detected.
+
+Action-scoped issues are only included when the user is allowed to view that action. Issues that are not tied to an action (for example unset environment variables or missing include directories) remain visible to anyone who may view Diagnostics.
+
+Users who are not allowed to view Diagnostics cannot retrieve the issue list.
+
+## Navigation count
+
+When Diagnostics is visible and at least one configuration issue exists that the user is allowed to see, the Diagnostics navigation link shows a count badge with the number of those issues. The badge clears when the visible issue count becomes zero after a configuration or entity refresh.
+
+## Startup count
+
+When the web UI starts, users who may view Diagnostics receive the same filtered configuration issue count used for the Diagnostics list and navigation badge. For other users the count is zero.

+ 35 - 0
specs/dashboard-nav-categories.md

@@ -0,0 +1,35 @@
+# Spec: Dashboard navigation categories
+
+This spec describes how root dashboards can be grouped into categories in the sidebar navigation.
+
+---
+
+## 1. Configuration
+
+Root dashboard entries in the configuration may include an optional category label.
+
+- The category applies only to root dashboards (top-level items in the dashboards list). Nested dashboard contents ignore category.
+- If category is omitted or empty, the dashboard is uncategorized.
+- Dashboards that share the same category label are grouped together under that label in the sidebar.
+
+## 2. Visibility
+
+Only dashboards the current user is allowed to view appear in navigation.
+
+- Access-denied dashboards are omitted from the list and do not create empty category sections.
+- If every dashboard in a category is hidden, that category does not appear.
+
+## 3. Sidebar ordering
+
+When building the sidebar:
+
+1. Uncategorized dashboards appear first, as a flat list above any category sections, in configuration order among visible uncategorized dashboards.
+2. Category sections follow, in the order each category first appears among visible categorized dashboards.
+3. Within a category, dashboards keep the order they appear in the configuration among visible dashboards in that category.
+4. After all dashboard links, a **System** category lists Entities, Logs, and Diagnostics (each only when the user is allowed to see that item). If none of those links are visible, the System category is omitted.
+
+The default Actions dashboard, when present, is always uncategorized.
+
+## 4. Navigation style
+
+Category sections apply when section navigation uses the sidebar. Top-bar navigation does not show category section headers; dashboard links still appear in the same relative order without collapsible category groups.

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor