Jelajahi Sumber

Merge branch 'next' of github.com:OliveTin/OliveTin into next

jamesread 1 hari lalu
induk
melakukan
5ee6effc1a
64 mengubah file dengan 1399 tambahan dan 823 penghapusan
  1. 4 1
      Makefile
  2. 40 13
      README.md
  3. 1 0
      docs/modules/ROOT/nav.adoc
  4. 2 0
      docs/modules/ROOT/pages/config.adoc
  5. 3 1
      docs/modules/ROOT/pages/security/content_security_policy.adoc
  6. 188 0
      docs/modules/ROOT/pages/security/embedding_iframe.adoc
  7. 149 564
      frontend/package-lock.json
  8. 1 1
      frontend/package.json
  9. 1 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.d.ts
  10. 1 1
      frontend/resources/scripts/gen/olivetin/api/v1/olivetin_pb.js
  11. 2 0
      frontend/resources/vue/components/ChoiceCombobox.vue
  12. 22 0
      frontend/resources/vue/utils/argumentInputType.js
  13. 29 0
      frontend/resources/vue/utils/argumentInputType.test.mjs
  14. 17 22
      frontend/resources/vue/views/ArgumentForm.vue
  15. 120 81
      integration-tests/package-lock.json
  16. 1 1
      proto/buf.gen.yaml
  17. 6 0
      renovate.json
  18. 3 3
      service/.golangci.yml
  19. 3 3
      service/go.mod
  20. 6 6
      service/go.sum
  21. 39 11
      service/internal/api/api.go
  22. 1 4
      service/internal/api/api_entities_list.go
  23. 12 8
      service/internal/api/api_entity_related_actions_test.go
  24. 3 3
      service/internal/api/api_init_search_hints_test.go
  25. 6 6
      service/internal/api/api_log_arguments_test.go
  26. 54 1
      service/internal/api/api_test.go
  27. 1 1
      service/internal/api/dashboard_acl_test.go
  28. 2 2
      service/internal/api/dashboards_race_test.go
  29. 1 1
      service/internal/auth/authpublic/authenticateduser.go
  30. 6 6
      service/internal/auth/otoauth2/restapi_auth_oauth2_test.go
  31. 8 8
      service/internal/config/config_reloader_user_test.go
  32. 4 3
      service/internal/config/entity_argument_choices_test.go
  33. 4 4
      service/internal/config/loaded_sources.go
  34. 2 2
      service/internal/entities/entities_test.go
  35. 3 3
      service/internal/executor/executor.go
  36. 34 34
      service/internal/executor/executor_test.go
  37. 1 1
      service/internal/executor/group_concurrency_test.go
  38. 6 6
      service/internal/executor/loadlogs.go
  39. 5 10
      service/internal/logfilter/filter.go
  40. 2 2
      service/internal/tpl/templates.go
  41. 3 2
      service/internal/tpl/templates_test.go
  42. 7 7
      service/internal/webhooks/github.go
  43. 2 0
      service/syntaxcheck.go
  44. 2 0
      var/marketing/screenshots/.gitignore
  45. 48 0
      var/marketing/screenshots/Makefile
  46. 41 0
      var/marketing/screenshots/README.md
  47. TEMPAT SAMPAH
      var/marketing/screenshots/argument-form.png
  48. TEMPAT SAMPAH
      var/marketing/screenshots/dashboard-dark.png
  49. TEMPAT SAMPAH
      var/marketing/screenshots/dashboard-mobile.png
  50. TEMPAT SAMPAH
      var/marketing/screenshots/dashboard.png
  51. TEMPAT SAMPAH
      var/marketing/screenshots/entities.png
  52. TEMPAT SAMPAH
      var/marketing/screenshots/execution.png
  53. 74 0
      var/marketing/screenshots/generate-manifest.py
  54. TEMPAT SAMPAH
      var/marketing/screenshots/logs.png
  55. 60 0
      var/marketing/screenshots/screenshot-manifest.yaml
  56. 58 0
      var/marketing/screenshots/screenshots.ini
  57. 10 0
      var/marketing/screenshots/scripts/argument_form.py
  58. 10 0
      var/marketing/screenshots/scripts/dark.py
  59. 10 0
      var/marketing/screenshots/scripts/light.py
  60. 19 0
      var/marketing/screenshots/scripts/prepare.py
  61. 12 0
      var/marketing/screenshots/scripts/theme.py
  62. 36 0
      var/marketing/screenshots/seed.sh
  63. 204 0
      var/marketing/screenshots/seed/config.yaml
  64. 10 0
      var/marketing/screenshots/seed/entities/servers.yaml

+ 4 - 1
Makefile

@@ -97,7 +97,10 @@ clean:
 config-tool:
 config-tool:
 	cd service && go run cmd/config-tool/main.go
 	cd service && go run cmd/config-tool/main.go
 
 
+screenshots:
+	$(MAKE) -wC var/marketing/screenshots
+
 devcheck:
 devcheck:
 	python3 scripts/devcheck.py $(ARGS)
 	python3 scripts/devcheck.py $(ARGS)
 
 
-.PHONY: proto proto-tools lang-generate generated-check default service windows-resources windows-msi frontend-unittests docs-check it devcheck lint test service-unittests service-codestyle frontend-codestyle
+.PHONY: screenshots proto proto-tools lang-generate generated-check default service windows-resources windows-msi frontend-unittests docs-check it devcheck lint test service-unittests service-codestyle frontend-codestyle

+ 40 - 13
README.md

@@ -15,7 +15,7 @@
 [OliveTin 2k to 3k upgrade guide](https://docs.olivetin.app/upgrade/2k3k.html)
 [OliveTin 2k to 3k upgrade guide](https://docs.olivetin.app/upgrade/2k3k.html)
 </div>
 </div>
 
 
-<img alt = "screenshot" src = "https://github.com/OliveTin/OliveTin/blob/main/var/marketing/screenshots/mainpage-laptop_framed.png" />
+<img alt = "OliveTin action buttons" src = "var/marketing/screenshots/dashboard.png" />
 <a href = "#screenshots">More screenshots below</a>
 <a href = "#screenshots">More screenshots below</a>
 
 
 All documentation can be found at [docs.olivetin.app](https://docs.olivetin.app). This includes installation and usage guide, etc.
 All documentation can be found at [docs.olivetin.app](https://docs.olivetin.app). This includes installation and usage guide, etc.
@@ -55,23 +55,50 @@ The AsciiDoc sources for that site live in this repository under [`docs/`](docs/
 
 
 ## Screenshots
 ## Screenshots
 
 
-Desktop web browser;
+### Action buttons
 
 
-<p align = "center">
-<img alt = "screenshot" src = "https://github.com/OliveTin/OliveTin/blob/main/var/marketing/screenshots/mainpage-laptop_framed.png" />
-</p>
+![Action buttons](var/marketing/screenshots/dashboard.png)
 
 
-Desktop web browser (dark mode);
+Your predefined shell commands appear as simple, touch-friendly buttons, so anyone can run them safely without a terminal.
 
 
-<p align = "center">
-<img alt = "screenshot" src = "https://github.com/OliveTin/OliveTin/blob/main/var/marketing/screenshots/mainpage-darkop_framed.png" />
-</p>
+### Arguments
 
 
-Mobile screen size (responsive layout);
+![Arguments](var/marketing/screenshots/argument-form.png)
 
 
-<p align = "center">
-<img alt = "screenshot" src = "https://github.com/OliveTin/OliveTin/blob/main/var/marketing/screenshots/mainpage-phone_framed.png" style = "width: 700px;" />
-</p>
+Actions can ask for input before they run, with typed and validated fields, defaults and dropdowns, so commands only ever run with sensible values.
+
+### Command output
+
+![Command output](var/marketing/screenshots/execution.png)
+
+Watch a command's output live as it runs, along with whether it succeeded and how long it took.
+
+### Dashboards and entities
+
+![Dashboards and entities](var/marketing/screenshots/entities.png)
+
+Build dashboards that group actions by the things you manage, such as servers or containers, with one set of buttons generated for each of them.
+
+### Execution logs
+
+![Execution logs](var/marketing/screenshots/logs.png)
+
+Every action that runs is logged, so you can see who ran what, when, and whether it succeeded.
+
+### Dark mode
+
+![Dark mode](var/marketing/screenshots/dashboard-dark.png)
+
+OliveTin follows your browser's dark mode preference automatically.
+
+### Mobile layout
+
+![Mobile layout](var/marketing/screenshots/dashboard-mobile.png)
+
+The responsive layout works well on phones and on wall-mounted tablets, so commands are a tap away wherever you are.
+
+Screenshots use fictional test data and are regenerated with `make screenshots`
+(see [`var/marketing/screenshots`](var/marketing/screenshots/README.md)).
 
 
 ## No-Nonsense Software Principles
 ## No-Nonsense Software Principles
 
 

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

@@ -121,6 +121,7 @@
 *** xref:security/example_some_admin_actions.adoc[Example: Some actions require admin]
 *** xref:security/example_some_admin_actions.adoc[Example: Some actions require admin]
 ** xref:security/design_choices.adoc[Security Design & Hardening Recommendations]
 ** xref:security/design_choices.adoc[Security Design & Hardening Recommendations]
 ** xref:security/content_security_policy.adoc[Content Security Policy headers]
 ** xref:security/content_security_policy.adoc[Content Security Policy headers]
+*** xref:security/embedding_iframe.adoc[Embedding OliveTin in an iframe]
 * Integrations
 * Integrations
 ** xref:integrations/homeassistant-integration.adoc[Home Assistant (HACS Integration)]
 ** xref:integrations/homeassistant-integration.adoc[Home Assistant (HACS Integration)]
 ** xref:integrations/homeassistant.adoc[Home Assistant (REST)]
 ** xref:integrations/homeassistant.adoc[Home Assistant (REST)]

+ 2 - 0
docs/modules/ROOT/pages/config.adoc

@@ -99,6 +99,8 @@ All configuration options are covered in the solution sections
 | `accessControlLists` | The list of access control lists. | `[]` | Requires restart | xref:security/acl.adoc[Access Control Lists]
 | `accessControlLists` | The list of access control lists. | `[]` | Requires restart | xref:security/acl.adoc[Access Control Lists]
 | `security.headerContentSecurityPolicy` | Whether to send a `Content-Security-Policy` header from the single HTTP frontend. | `true` | Live reloadable | xref:security/content_security_policy.adoc[Content Security Policy headers]
 | `security.headerContentSecurityPolicy` | Whether to send a `Content-Security-Policy` header from the single HTTP frontend. | `true` | Live reloadable | xref:security/content_security_policy.adoc[Content Security Policy headers]
 | `security.contentSecurityPolicy` | CSP header value when `security.headerContentSecurityPolicy` is enabled. If empty, a built-in default is used. | (built-in default) | Live reloadable | xref:security/content_security_policy.adoc[Content Security Policy headers]
 | `security.contentSecurityPolicy` | CSP header value when `security.headerContentSecurityPolicy` is enabled. If empty, a built-in default is used. | (built-in default) | Live reloadable | xref:security/content_security_policy.adoc[Content Security Policy headers]
+| `security.headerXFrameOptions` | Whether to send an `X-Frame-Options` header from the single HTTP frontend. | `true` | Live reloadable | xref:security/embedding_iframe.adoc[Embedding OliveTin in an iframe]
+| `security.xFrameOptions` | `X-Frame-Options` header value when `security.headerXFrameOptions` is enabled. | `DENY` | Live reloadable | xref:security/embedding_iframe.adoc[Embedding OliveTin in an iframe]
 |===
 |===
 
 
 == Networking Configuration
 == Networking Configuration

+ 3 - 1
docs/modules/ROOT/pages/security/content_security_policy.adoc

@@ -3,6 +3,8 @@
 
 
 When xref:reference/network-ports.adoc[the single HTTP frontend] is enabled (the default), OliveTin adds several browser security headers to every response, including `Content-Security-Policy`. This page explains how to turn that header off or replace it with a less strict policy when you need to (for example, custom scripts, different API hosts, or embedding in an iframe).
 When xref:reference/network-ports.adoc[the single HTTP frontend] is enabled (the default), OliveTin adds several browser security headers to every response, including `Content-Security-Policy`. This page explains how to turn that header off or replace it with a less strict policy when you need to (for example, custom scripts, different API hosts, or embedding in an iframe).
 
 
+TIP: If you want to show OliveTin inside another page, such as a Home Assistant dashboard, follow xref:security/embedding_iframe.adoc[Embedding OliveTin in an iframe] instead. It has a ready-to-paste example.
+
 [WARNING]
 [WARNING]
 Relaxing or removing CSP weakens protection against cross-site scripting and related attacks. Prefer the smallest change that fixes your issue, and keep the rest of the policy as tight as you can.
 Relaxing or removing CSP weakens protection against cross-site scripting and related attacks. Prefer the smallest change that fixes your issue, and keep the rest of the policy as tight as you can.
 
 
@@ -43,7 +45,7 @@ Build your policy from the https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
 Typical reasons people adjust this setting:
 Typical reasons people adjust this setting:
 
 
 * **Custom JavaScript or third-party scripts** — You may need to extend `script-src` (and sometimes `connect-src` for XHR/fetch). See xref:advanced_configuration/webui.adoc#custom-js[Custom JavaScript].
 * **Custom JavaScript or third-party scripts** — You may need to extend `script-src` (and sometimes `connect-src` for XHR/fetch). See xref:advanced_configuration/webui.adoc#custom-js[Custom JavaScript].
-* **Embedding OliveTin in another site** — The default includes `frame-ancestors 'none'`, which blocks iframes. You must change that directive (and may need to relax `X-Frame-Options` using `security.headerXFrameOptions` and `security.xFrameOptions`) if embedding is required.
+* **Embedding OliveTin in another site** — The default includes `frame-ancestors 'none'`, which blocks iframes. See xref:security/embedding_iframe.adoc[Embedding OliveTin in an iframe] for step-by-step instructions.
 * **API or auth on another origin** — Extend `connect-src` (and possibly `form-action` or others) to include those URLs.
 * **API or auth on another origin** — Extend `connect-src` (and possibly `form-action` or others) to include those URLs.
 * **Reverse proxy also sets CSP** — Your proxy might add a second policy; browsers combine them. Align OliveTin and the proxy so you do not get conflicting or unexpectedly strict effective policies.
 * **Reverse proxy also sets CSP** — Your proxy might add a second policy; browsers combine them. Align OliveTin and the proxy so you do not get conflicting or unexpectedly strict effective policies.
 
 

+ 188 - 0
docs/modules/ROOT/pages/security/embedding_iframe.adoc

@@ -0,0 +1,188 @@
+[#embedding-iframe]
+= Embedding OliveTin in an iframe
+
+You might want to show OliveTin inside another web page, such as a Home Assistant dashboard (Webpage card), Homarr, Organizr, Heimdall, or your own HTML page. These pages show OliveTin using an `<iframe>`.
+
+By default, **OliveTin tells web browsers not to allow this**. This protects you from "clickjacking", where a malicious website hides your OliveTin buttons inside its own page and tricks you into clicking them. If you try to embed OliveTin without changing anything, the iframe will be blank and the browser console will show an error like this:
+
+[source,text]
+----
+Framing violates the following Content Security Policy directive: "frame-ancestors 'none'". The request has been blocked.
+----
+
+This page explains how to allow *your* dashboard to embed OliveTin, while still blocking every other website.
+
+TIP: You do not need to understand Content Security Policy (CSP) to follow this page. You only need to change one web address in the example below.
+
+== Step 1: Find the address of your dashboard
+
+You need the **origin** of the page that will contain OliveTin, which we'll call the "parent page". This is *not* the address of OliveTin itself.
+
+The easiest way to get it exactly right:
+
+. Open your dashboard (the parent page) in your web browser.
+. Open the browser's developer tools (press kbd:[F12]) and go to the **Console** tab.
+. Type `location.origin` and press kbd:[Enter].
+
+The browser will print something like `"https://homeassistant.example.com"` or `"http://192.168.1.50:8123"`. Copy it, without the quotes. That is your origin.
+
+An origin is made up of three parts, and **all three must match exactly**:
+
+[cols="1,2,2"]
+|===
+| Part | Example | Notes
+
+| Scheme
+| `http://` or `https://`
+| `http` and `https` are treated as different sites.
+
+| Host
+| `homeassistant.example.com` or `192.168.1.50`
+| If you sometimes open your dashboard by IP address and sometimes by name, those are two different origins. Allow both (see <<multiple-dashboards>>).
+
+| Port
+| `:8123`
+| Only included if it is not the default (`80` for http, `443` for https).
+|===
+
+Paths such as `/lovelace/home` are not part of the origin. Leave them out.
+
+== Step 2: Add this to your config.yaml
+
+Copy this into your OliveTin `config.yaml`. If you already have a `security:` section, merge these keys into it rather than adding a second `security:` section.
+
+include::partial$config-start.adoc[]
+----
+security:
+  headerXFrameOptions: false
+  contentSecurityPolicy: "default-src 'self'; script-src 'self' 'unsafe-inline' https:; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; connect-src 'self' https:; frame-ancestors 'self' https://dashboard.example.com; base-uri 'self'"
+----
+
+**The only thing you need to change** is `https://dashboard.example.com`. Replace it with the origin you found in Step 1. Leave everything else on that line exactly as it is, including the quotes and semicolons.
+
+For example, if your Home Assistant is at `http://192.168.1.50:8123`, the `frame-ancestors` part becomes:
+
+[source,text]
+----
+frame-ancestors 'self' http://192.168.1.50:8123;
+----
+
+What these two settings do:
+
+* `contentSecurityPolicy` is OliveTin's normal security policy, with one change. `frame-ancestors 'none'` (nobody may embed OliveTin) has become `frame-ancestors 'self' <your dashboard>` (only OliveTin itself and your dashboard may embed it).
+* `headerXFrameOptions: false` turns off an older header, `X-Frame-Options: DENY`, which also blocks iframes. The `frame-ancestors` setting above does the same job in a more flexible way, so this older header is no longer needed.
+
+[#multiple-dashboards]
+=== Allowing more than one dashboard
+
+To allow several parent pages, list them after `frame-ancestors`, separated by spaces:
+
+[source,text]
+----
+frame-ancestors 'self' https://homeassistant.example.com http://192.168.1.50:8123 https://homarr.example.com;
+----
+
+To allow every subdomain of your own domain, use a `*` wildcard:
+
+[source,text]
+----
+frame-ancestors 'self' https://*.example.com;
+----
+
+=== Allowing any website (not recommended)
+
+If you are testing on a private network and just want it to work, you can allow any page to embed OliveTin:
+
+[source,text]
+----
+frame-ancestors *;
+----
+
+WARNING: This means *any* website you visit can embed your OliveTin and trick you into clicking its buttons. Only use this for testing, and never on an OliveTin instance that is reachable from the internet.
+
+== Step 3: Reload and test
+
+Changes to the `security` section are picked up automatically when you save `config.yaml`, so you normally do not need to restart OliveTin. If you are unsure, restarting is harmless.
+
+Then reload your dashboard page in the browser. If you use the browser's cache heavily, a hard refresh (kbd:[Ctrl+Shift+R]) helps.
+
+If you're writing your own HTML page, the iframe looks like this:
+
+[source,html]
+----
+<iframe src="https://olivetin.example.com/" style="width: 100%; height: 600px; border: 0"></iframe>
+----
+
+=== Checking what OliveTin is sending
+
+If it still does not work, check the headers that are actually being sent. Run this from a terminal, using the address you use to reach OliveTin:
+
+[source,shell]
+----
+curl -sI https://olivetin.example.com/ | grep -i -E 'content-security|x-frame'
+----
+
+You should see your dashboard's origin in the `frame-ancestors` part, and no `X-Frame-Options` line. If you still see `frame-ancestors 'none'`, your config change has not been applied. See the troubleshooting section below.
+
+== Troubleshooting
+
+[cols="2,3"]
+|===
+| What you see | What to do
+
+| The error still says `frame-ancestors 'none'`
+a| OliveTin is still sending its default policy. Check that:
+
+* the keys are indented under `security:` and spelled exactly as shown, including upper and lower case letters (`contentSecurityPolicy` starts with a lower case `c`).
+* you edited the `config.yaml` that OliveTin is actually using (check the OliveTin startup logs).
+* you replaced `'none'` in the line you pasted. Older examples on the xref:security/content_security_policy.adoc[Content Security Policy] page still contain `'none'`.
+* a reverse proxy is not adding its own `Content-Security-Policy` header (see below).
+
+| The error lists your dashboard, but it is still blocked
+| The origin does not match exactly. Compare it carefully with the output of `location.origin` from Step 1. The most common mistakes are `http` vs `https`, a missing port number, or using an IP address in one place and a hostname in the other.
+
+| `Refused to display ... in a frame because it set 'X-Frame-Options' to 'deny'`
+| Add `headerXFrameOptions: false` under `security:`, as shown in Step 2. You may also see this if your reverse proxy adds its own `X-Frame-Options` header.
+
+| `Mixed Content: The page at 'https://...' was loaded over HTTPS, but requested an insecure frame 'http://...'`
+| Your dashboard uses `https://` but OliveTin uses `http://`. Browsers do not allow this, and no OliveTin setting can change it. Serve OliveTin over HTTPS too, for example behind a xref:reverse-proxies/intro.adoc[reverse proxy].
+
+| The iframe works, but you have to log in every time or logging in does nothing
+| See <<logins-in-iframes>>.
+|===
+
+=== Reverse proxies
+
+Some reverse proxies and proxy add-ons (for example Traefik security header middlewares, Nginx Proxy Manager "block common exploits", or authentication proxies) add their own `Content-Security-Policy` or `X-Frame-Options` headers. Browsers enforce *every* policy they receive, so a strict policy from your proxy will still block the iframe even after you have changed OliveTin.
+
+To find out if this is happening, run the `curl` command from Step 3 twice: once directly against OliveTin (for example `http://192.168.1.10:1337/`) and once against the address that goes through your proxy. If the results are different, change the headers in your proxy configuration as well.
+
+[#logins-in-iframes]
+=== Logins inside an iframe
+
+If you use xref:security/local.adoc[local users] or xref:security/oauth2.adoc[OAuth2], OliveTin remembers that you are logged in using a cookie. Browsers only send OliveTin's cookie to an iframe when the parent page and OliveTin are on the **same site**. The same site means the same scheme (`http` or `https`) and the same main domain. Subdomains and port numbers can differ.
+
+[cols="2,2,1"]
+|===
+| Dashboard | OliveTin | Login works in iframe?
+
+| `https://ha.example.com`
+| `https://olivetin.example.com`
+| Yes
+
+| `http://192.168.1.50:8123`
+| `http://192.168.1.50:1337`
+| Yes
+
+| `https://ha.example.com`
+| `https://olivetin.otherdomain.net`
+| No
+
+| `https://ha.example.com`
+| `http://192.168.1.50:1337`
+| No
+|===
+
+If your setup is in the "No" rows, the easiest fix is to make OliveTin available on a subdomain of the same domain as your dashboard, usually with a xref:reverse-proxies/intro.adoc[reverse proxy].
+
+Many OAuth2 login providers also refuse to show their login page inside an iframe. If the login page does not appear, open OliveTin in a normal browser tab, log in there, and then reload your dashboard. This only works when the parent page and OliveTin are on the same site, as described above.

File diff ditekan karena terlalu besar
+ 149 - 564
frontend/package-lock.json


+ 1 - 1
frontend/package.json

@@ -37,6 +37,6 @@
 		"vue-router": "^5.4.0"
 		"vue-router": "^5.4.0"
 	},
 	},
 	"engines": {
 	"engines": {
-		"node": "^20.19.0 || >=22.12.0"
+		"node": "^22.13.0 || >=24"
 	}
 	}
 }
 }

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

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

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

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

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

@@ -313,6 +313,8 @@ onBeforeUnmount(() => {
 
 
 .choice-combobox-input {
 .choice-combobox-input {
   width: 100%;
   width: 100%;
+  max-width: 100%;
+  box-sizing: border-box;
 }
 }
 
 
 .choice-combobox-list {
 .choice-combobox-list {

+ 22 - 0
frontend/resources/vue/utils/argumentInputType.js

@@ -0,0 +1,22 @@
+const typesWithoutInputType = ['html', 'raw_string_multiline', 'select']
+
+const htmlInputTypesByArgumentType = {
+  checkbox: 'checkbox',
+  confirmation: 'checkbox',
+  datetime: 'datetime-local',
+  email: 'email',
+  int: 'number',
+  password: 'password',
+  url: 'url'
+}
+
+// Argument types are OliveTin validation types, not HTML input types; anything
+// without a matching HTML type renders as text so that it is styled and the
+// browser does not have to guess.
+export function argumentInputType (arg) {
+  if (typesWithoutInputType.includes(arg.type)) {
+    return undefined
+  }
+
+  return htmlInputTypesByArgumentType[arg.type] ?? 'text'
+}

+ 29 - 0
frontend/resources/vue/utils/argumentInputType.test.mjs

@@ -0,0 +1,29 @@
+import test from 'node:test'
+import assert from 'node:assert/strict'
+
+import { argumentInputType } from './argumentInputType.js'
+
+test('argumentInputType renders int arguments as number inputs', () => {
+  assert.equal(argumentInputType({ type: 'int' }), 'number')
+})
+
+test('argumentInputType maps argument types to valid HTML input types', () => {
+  assert.equal(argumentInputType({ type: 'confirmation' }), 'checkbox')
+  assert.equal(argumentInputType({ type: 'checkbox' }), 'checkbox')
+  assert.equal(argumentInputType({ type: 'datetime' }), 'datetime-local')
+  assert.equal(argumentInputType({ type: 'email' }), 'email')
+  assert.equal(argumentInputType({ type: 'password' }), 'password')
+  assert.equal(argumentInputType({ type: 'url' }), 'url')
+})
+
+test('argumentInputType renders validation-only types as text inputs', () => {
+  for (const type of ['', 'ascii', 'ascii_identifier', 'ascii_sentence', 'dnsname', 'shell_safe_identifier', 'unicode_identifier', 'very_dangerous_raw_string', 'regex:^[a-z]+$']) {
+    assert.equal(argumentInputType({ type }), 'text', type)
+  }
+})
+
+test('argumentInputType has no input type for non-input arguments', () => {
+  for (const type of ['html', 'raw_string_multiline', 'select']) {
+    assert.equal(argumentInputType({ type }), undefined, type)
+  }
+})

+ 17 - 22
frontend/resources/vue/views/ArgumentForm.vue

@@ -87,7 +87,7 @@
               :value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
               :value="(arg.type === 'checkbox' || arg.type === 'confirmation') ? undefined : getArgumentValue(arg)"
               :checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
               :checked="(arg.type === 'checkbox' || arg.type === 'confirmation') ? getArgumentValue(arg) : undefined"
               :list="(arg.suggestions || getBrowserSuggestions(arg).length > 0) ? argumentFieldChoicesId(arg.name) : undefined"
               :list="(arg.suggestions || getBrowserSuggestions(arg).length > 0) ? argumentFieldChoicesId(arg.name) : undefined"
-              :type="getInputComponent(arg) !== 'select' ? getInputType(arg) : undefined"
+              :type="getInputComponent(arg) !== 'select' ? argumentInputType(arg) : undefined"
               :rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
               :rows="arg.type === 'raw_string_multiline' ? 5 : undefined"
               :step="arg.type === 'datetime' ? 1 : undefined"
               :step="arg.type === 'datetime' ? 1 : undefined"
               :pattern="getPattern(arg)"
               :pattern="getPattern(arg)"
@@ -158,6 +158,7 @@ import {
   argumentFieldId,
   argumentFieldId,
   argumentFieldValidationElementId
   argumentFieldValidationElementId
 } from '../utils/argumentFieldIds.js'
 } from '../utils/argumentFieldIds.js'
+import { argumentInputType } from '../utils/argumentInputType.js'
 import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from '../utils/prefilledArguments.js'
 import { getInitialArgumentValue, readPrefilledArgumentsFromNavigation } from '../utils/prefilledArguments.js'
 
 
 const router = useRouter()
 const router = useRouter()
@@ -285,26 +286,6 @@ function getInputComponent (arg) {
   }
   }
 }
 }
 
 
-function getInputType (arg) {
-  if (arg.type === 'html' || arg.type === 'raw_string_multiline' || arg.type === 'select') {
-    return undefined
-  }
-
-  if (arg.type === 'confirmation') {
-    return 'checkbox'
-  }
-
-  if (arg.type === 'ascii_identifier' || arg.type === 'dnsname' || arg.type === 'shell_safe_identifier' || arg.type === 'ascii' || arg.type === 'ascii_sentence') {
-    return 'text'
-  }
-
-  if (arg.type === 'datetime') {
-    return 'datetime-local'
-  }
-
-  return arg.type
-}
-
 function getPattern (arg) {
 function getPattern (arg) {
   if (arg.type && arg.type.startsWith('regex:')) {
   if (arg.type && arg.type.startsWith('regex:')) {
     return arg.type.replace('regex:', '')
     return arg.type.replace('regex:', '')
@@ -700,7 +681,21 @@ onUnmounted(() => {
 }
 }
 
 
 form {
 form {
-  grid-template-columns: max-content auto auto;
+  grid-template-columns: minmax(0, max-content) minmax(0, auto) minmax(0, auto);
+}
+
+@media (max-width: 600px) {
+  form {
+    grid-template-columns: minmax(0, 1fr);
+  }
+
+  form input:not([type="checkbox"]):not([type="radio"]),
+  form textarea,
+  form select {
+    width: 100%;
+    max-width: 100%;
+    box-sizing: border-box;
+  }
 }
 }
 
 
 .argument-description {
 .argument-description {

+ 120 - 81
integration-tests/package-lock.json

@@ -20,15 +20,6 @@
         "selenium-webdriver": "^4.51.0"
         "selenium-webdriver": "^4.51.0"
       }
       }
     },
     },
-    "node_modules/@aashutoshrathi/word-wrap": {
-      "version": "1.2.6",
-      "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz",
-      "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==",
-      "dev": true,
-      "engines": {
-        "node": ">=0.10.0"
-      }
-    },
     "node_modules/@bazel/runfiles": {
     "node_modules/@bazel/runfiles": {
       "version": "6.5.0",
       "version": "6.5.0",
       "resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz",
       "resolved": "https://registry.npmjs.org/@bazel/runfiles/-/runfiles-6.5.0.tgz",
@@ -61,9 +52,9 @@
       }
       }
     },
     },
     "node_modules/@eslint-community/eslint-utils": {
     "node_modules/@eslint-community/eslint-utils": {
-      "version": "4.9.0",
-      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz",
-      "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==",
+      "version": "4.10.1",
+      "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+      "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
@@ -279,6 +270,7 @@
       "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
       "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
       "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
       "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
       "dev": true,
       "dev": true,
+      "license": "Apache-2.0",
       "engines": {
       "engines": {
         "node": ">=12.22"
         "node": ">=12.22"
       },
       },
@@ -288,9 +280,9 @@
       }
       }
     },
     },
     "node_modules/@humanwhocodes/retry": {
     "node_modules/@humanwhocodes/retry": {
-      "version": "0.4.2",
-      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz",
-      "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==",
+      "version": "0.4.3",
+      "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+      "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
       "dev": true,
       "dev": true,
       "license": "Apache-2.0",
       "license": "Apache-2.0",
       "engines": {
       "engines": {
@@ -339,9 +331,9 @@
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/@types/estree": {
     "node_modules/@types/estree": {
-      "version": "1.0.8",
-      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
-      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "version": "1.0.9",
+      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+      "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
       "dev": true,
       "dev": true,
       "license": "MIT"
       "license": "MIT"
     },
     },
@@ -353,9 +345,9 @@
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/acorn": {
     "node_modules/acorn": {
-      "version": "8.16.0",
-      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
-      "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
+      "version": "8.19.0",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
+      "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "bin": {
       "bin": {
@@ -388,9 +380,9 @@
       }
       }
     },
     },
     "node_modules/ajv": {
     "node_modules/ajv": {
-      "version": "6.14.0",
-      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
-      "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
+      "version": "6.15.0",
+      "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+      "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
       "dev": true,
       "dev": true,
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
@@ -405,11 +397,21 @@
       }
       }
     },
     },
     "node_modules/argparse": {
     "node_modules/argparse": {
-      "version": "2.0.1",
-      "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
-      "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.3.tgz",
+      "integrity": "sha512-Sn98ftV3KfsO8xfLPHG6oT8y+b0yvbrLgZjgPDXsharNY15tTAjqIsIju4S32nl6/iEUNNE4v+yL2Zhm8ooP3w==",
       "dev": true,
       "dev": true,
-      "license": "Python-2.0"
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/puzrin"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/nodeca"
+        }
+      ],
+      "license": "PSF-2.0"
     },
     },
     "node_modules/asynckit": {
     "node_modules/asynckit": {
       "version": "0.4.0",
       "version": "0.4.0",
@@ -429,6 +431,16 @@
         "proxy-from-env": "^2.1.0"
         "proxy-from-env": "^2.1.0"
       }
       }
     },
     },
+    "node_modules/balanced-match": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "18 || 20 || >=22"
+      }
+    },
     "node_modules/brace-expansion": {
     "node_modules/brace-expansion": {
       "version": "5.0.12",
       "version": "5.0.12",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
       "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
@@ -442,21 +454,12 @@
         "node": "20 || >=22"
         "node": "20 || >=22"
       }
       }
     },
     },
-    "node_modules/brace-expansion/node_modules/balanced-match": {
-      "version": "4.0.4",
-      "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
-      "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
-      "dev": true,
-      "license": "MIT",
-      "engines": {
-        "node": "18 || 20 || >=22"
-      }
-    },
     "node_modules/browser-stdout": {
     "node_modules/browser-stdout": {
       "version": "1.3.1",
       "version": "1.3.1",
       "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
       "resolved": "https://registry.npmjs.org/browser-stdout/-/browser-stdout-1.3.1.tgz",
       "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
       "integrity": "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==",
-      "dev": true
+      "dev": true,
+      "license": "ISC"
     },
     },
     "node_modules/cacheable": {
     "node_modules/cacheable": {
       "version": "2.5.0",
       "version": "2.5.0",
@@ -546,9 +549,9 @@
       }
       }
     },
     },
     "node_modules/debug": {
     "node_modules/debug": {
-      "version": "4.4.0",
-      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
-      "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+      "version": "4.4.3",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
         "ms": "^2.1.3"
         "ms": "^2.1.3"
@@ -566,7 +569,8 @@
       "version": "0.1.4",
       "version": "0.1.4",
       "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
       "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
       "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
       "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     },
     "node_modules/delayed-stream": {
     "node_modules/delayed-stream": {
       "version": "1.0.0",
       "version": "1.0.0",
@@ -651,6 +655,7 @@
       "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
       "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
       "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
       "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "engines": {
       "engines": {
         "node": ">=10"
         "node": ">=10"
       },
       },
@@ -831,7 +836,8 @@
       "version": "2.0.6",
       "version": "2.0.6",
       "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
       "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
       "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
       "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     },
     "node_modules/file-entry-cache": {
     "node_modules/file-entry-cache": {
       "version": "11.1.5",
       "version": "11.1.5",
@@ -848,6 +854,7 @@
       "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
       "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
       "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
       "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "dependencies": {
       "dependencies": {
         "locate-path": "^6.0.0",
         "locate-path": "^6.0.0",
         "path-exists": "^4.0.0"
         "path-exists": "^4.0.0"
@@ -996,6 +1003,7 @@
       "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
       "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
       "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
       "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
       "dev": true,
       "dev": true,
+      "license": "ISC",
       "dependencies": {
       "dependencies": {
         "is-glob": "^4.0.3"
         "is-glob": "^4.0.3"
       },
       },
@@ -1033,6 +1041,7 @@
       "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
       "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
       "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
       "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "engines": {
       "engines": {
         "node": ">=8"
         "node": ">=8"
       }
       }
@@ -1131,6 +1140,7 @@
       "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
       "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
       "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
       "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "engines": {
       "engines": {
         "node": ">=0.8.19"
         "node": ">=0.8.19"
       }
       }
@@ -1147,6 +1157,7 @@
       "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
       "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
       "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
       "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "engines": {
       "engines": {
         "node": ">=0.10.0"
         "node": ">=0.10.0"
       }
       }
@@ -1156,6 +1167,7 @@
       "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
       "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
       "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
       "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "dependencies": {
       "dependencies": {
         "is-extglob": "^2.1.1"
         "is-extglob": "^2.1.1"
       },
       },
@@ -1200,7 +1212,8 @@
       "version": "2.0.0",
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
       "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
       "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
       "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
-      "dev": true
+      "dev": true,
+      "license": "ISC"
     },
     },
     "node_modules/joi": {
     "node_modules/joi": {
       "version": "18.3.0",
       "version": "18.3.0",
@@ -1221,9 +1234,9 @@
       }
       }
     },
     },
     "node_modules/js-yaml": {
     "node_modules/js-yaml": {
-      "version": "5.4.1",
-      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.1.tgz",
-      "integrity": "sha512-28R/k+NAjeuf7+CKlTxWZVExJGwVVLwY06DgEnOMz2gEpfNkDcD7QvyiVPT0xy0XXhU8vHsd4Ot42OOPdJG7dQ==",
+      "version": "5.5.0",
+      "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.5.0.tgz",
+      "integrity": "sha512-paNr4LFIFPjeeSmwNYdrb8FafLLG/Cr2Af3GjbsHJ5CxIjH4r0c2emlmRdAtpPAieP1gRVzk6buBbVP5gBMGAg==",
       "dev": true,
       "dev": true,
       "funding": [
       "funding": [
         {
         {
@@ -1237,7 +1250,7 @@
       ],
       ],
       "license": "MIT",
       "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "argparse": "^2.0.1"
+        "argparse": "^3.0.3"
       },
       },
       "bin": {
       "bin": {
         "js-yaml": "bin/js-yaml.mjs"
         "js-yaml": "bin/js-yaml.mjs"
@@ -1254,7 +1267,8 @@
       "version": "1.0.1",
       "version": "1.0.1",
       "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
       "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
       "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
       "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     },
     "node_modules/jszip": {
     "node_modules/jszip": {
       "version": "3.10.2",
       "version": "3.10.2",
@@ -1284,6 +1298,7 @@
       "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
       "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
       "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
       "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "dependencies": {
       "dependencies": {
         "prelude-ls": "^1.2.1",
         "prelude-ls": "^1.2.1",
         "type-check": "~0.4.0"
         "type-check": "~0.4.0"
@@ -1307,6 +1322,7 @@
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
       "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
       "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
       "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "dependencies": {
       "dependencies": {
         "p-locate": "^5.0.0"
         "p-locate": "^5.0.0"
       },
       },
@@ -1324,9 +1340,9 @@
       "license": "MIT"
       "license": "MIT"
     },
     },
     "node_modules/lru-cache": {
     "node_modules/lru-cache": {
-      "version": "11.5.2",
-      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
-      "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+      "version": "11.5.3",
+      "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz",
+      "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==",
       "dev": true,
       "dev": true,
       "license": "BlueOak-1.0.0",
       "license": "BlueOak-1.0.0",
       "engines": {
       "engines": {
@@ -1364,13 +1380,13 @@
       }
       }
     },
     },
     "node_modules/minimatch": {
     "node_modules/minimatch": {
-      "version": "10.2.5",
-      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
-      "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+      "version": "10.2.6",
+      "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
+      "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
       "dev": true,
       "dev": true,
       "license": "BlueOak-1.0.0",
       "license": "BlueOak-1.0.0",
       "dependencies": {
       "dependencies": {
-        "brace-expansion": "^5.0.5"
+        "brace-expansion": "^5.0.8"
       },
       },
       "engines": {
       "engines": {
         "node": "18 || 20 || >=22"
         "node": "18 || 20 || >=22"
@@ -1420,21 +1436,6 @@
         "node": "^20.19.0 || >=22.12.0"
         "node": "^20.19.0 || >=22.12.0"
       }
       }
     },
     },
-    "node_modules/mocha/node_modules/supports-color": {
-      "version": "8.1.1",
-      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
-      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
-      "dev": true,
-      "dependencies": {
-        "has-flag": "^4.0.0"
-      },
-      "engines": {
-        "node": ">=10"
-      },
-      "funding": {
-        "url": "https://github.com/chalk/supports-color?sponsor=1"
-      }
-    },
     "node_modules/ms": {
     "node_modules/ms": {
       "version": "2.1.3",
       "version": "2.1.3",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
       "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1445,20 +1446,22 @@
       "version": "1.4.0",
       "version": "1.4.0",
       "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
       "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
       "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
       "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
-      "dev": true
+      "dev": true,
+      "license": "MIT"
     },
     },
     "node_modules/optionator": {
     "node_modules/optionator": {
-      "version": "0.9.3",
-      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz",
-      "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==",
+      "version": "0.9.4",
+      "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+      "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "dependencies": {
       "dependencies": {
-        "@aashutoshrathi/word-wrap": "^1.2.3",
         "deep-is": "^0.1.3",
         "deep-is": "^0.1.3",
         "fast-levenshtein": "^2.0.6",
         "fast-levenshtein": "^2.0.6",
         "levn": "^0.4.1",
         "levn": "^0.4.1",
         "prelude-ls": "^1.2.1",
         "prelude-ls": "^1.2.1",
-        "type-check": "^0.4.0"
+        "type-check": "^0.4.0",
+        "word-wrap": "^1.2.5"
       },
       },
       "engines": {
       "engines": {
         "node": ">= 0.8.0"
         "node": ">= 0.8.0"
@@ -1469,6 +1472,7 @@
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
       "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
       "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
       "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "dependencies": {
       "dependencies": {
         "yocto-queue": "^0.1.0"
         "yocto-queue": "^0.1.0"
       },
       },
@@ -1484,6 +1488,7 @@
       "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
       "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
       "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
       "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "dependencies": {
       "dependencies": {
         "p-limit": "^3.0.2"
         "p-limit": "^3.0.2"
       },
       },
@@ -1506,6 +1511,7 @@
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
       "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
       "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
       "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "engines": {
       "engines": {
         "node": ">=8"
         "node": ">=8"
       }
       }
@@ -1515,6 +1521,7 @@
       "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
       "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
       "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
       "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "engines": {
       "engines": {
         "node": ">=8"
         "node": ">=8"
       }
       }
@@ -1548,6 +1555,7 @@
       "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
       "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
       "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
       "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "engines": {
       "engines": {
         "node": ">= 0.8.0"
         "node": ">= 0.8.0"
       }
       }
@@ -1671,9 +1679,9 @@
       }
       }
     },
     },
     "node_modules/serialize-javascript": {
     "node_modules/serialize-javascript": {
-      "version": "7.1.2",
-      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.2.tgz",
-      "integrity": "sha512-GL2BWwVa6JydKO6l/ljVgjAZF4QJ3S7dWDWi53s5GZT8PJzD2fNxi67HANQGKAqPrwEpL5ba7gUBGi0Ls/sEoQ==",
+      "version": "7.1.3",
+      "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.1.3.tgz",
+      "integrity": "sha512-kYEnegpAxWFjUw3dSYdSM9zVlTrsxDbsSkq/qd3B7VanqyJ+JdDy6qVyuqVGBhSfuD/rLZQEKuXsO5KZFCrVYQ==",
       "dev": true,
       "dev": true,
       "license": "BSD-3-Clause",
       "license": "BSD-3-Clause",
       "engines": {
       "engines": {
@@ -1692,6 +1700,7 @@
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
       "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
       "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
       "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "dependencies": {
       "dependencies": {
         "shebang-regex": "^3.0.0"
         "shebang-regex": "^3.0.0"
       },
       },
@@ -1704,6 +1713,7 @@
       "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
       "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
       "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
       "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "engines": {
       "engines": {
         "node": ">=8"
         "node": ">=8"
       }
       }
@@ -1731,6 +1741,22 @@
         "url": "https://github.com/sponsors/sindresorhus"
         "url": "https://github.com/sponsors/sindresorhus"
       }
       }
     },
     },
+    "node_modules/supports-color": {
+      "version": "8.1.1",
+      "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz",
+      "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "has-flag": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/chalk/supports-color?sponsor=1"
+      }
+    },
     "node_modules/tmp": {
     "node_modules/tmp": {
       "version": "0.2.7",
       "version": "0.2.7",
       "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
       "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
@@ -1752,6 +1778,7 @@
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
       "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
       "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
       "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "dependencies": {
       "dependencies": {
         "prelude-ls": "^1.2.1"
         "prelude-ls": "^1.2.1"
       },
       },
@@ -1799,6 +1826,7 @@
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
       "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
       "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
       "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
       "dev": true,
       "dev": true,
+      "license": "ISC",
       "dependencies": {
       "dependencies": {
         "isexe": "^2.0.0"
         "isexe": "^2.0.0"
       },
       },
@@ -1809,6 +1837,16 @@
         "node": ">= 8"
         "node": ">= 8"
       }
       }
     },
     },
+    "node_modules/word-wrap": {
+      "version": "1.2.5",
+      "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+      "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
     "node_modules/workerpool": {
     "node_modules/workerpool": {
       "version": "10.0.3",
       "version": "10.0.3",
       "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-10.0.3.tgz",
       "resolved": "https://registry.npmjs.org/workerpool/-/workerpool-10.0.3.tgz",
@@ -1843,6 +1881,7 @@
       "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
       "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
       "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
       "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
       "dev": true,
       "dev": true,
+      "license": "MIT",
       "engines": {
       "engines": {
         "node": ">=10"
         "node": ">=10"
       },
       },

+ 1 - 1
proto/buf.gen.yaml

@@ -4,7 +4,7 @@ plugins:
     out: ../service/gen/
     out: ../service/gen/
     opt: paths=source_relative
     opt: paths=source_relative
 
 
-  - remote: buf.build/connectrpc/go
+  - remote: buf.build/connectrpc/go:v1.21.0
     out: ../service/gen/
     out: ../service/gen/
     opt: paths=source_relative
     opt: paths=source_relative
 
 

+ 6 - 0
renovate.json

@@ -0,0 +1,6 @@
+{
+  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+  "extends": ["config:recommended", ":semanticCommits", "group:allNonMajor"],
+  "schedule": ["before 6am on monday"],
+  "postUpdateOptions": ["gomodTidy"]
+}

+ 3 - 3
service/.golangci.yml

@@ -28,15 +28,15 @@ linters:
     - govet
     - govet
     - ineffassign
     - ineffassign
     - misspell
     - misspell
-#    - modernize
+    - modernize
     - nilerr
     - nilerr
     - noctx
     - noctx
 #    - promlinter
 #    - promlinter
     - staticcheck
     - staticcheck
-#    - testifylint
+    - testifylint
     - thelper
     - thelper
     - unconvert
     - unconvert
-#    - unparam
+    - unparam
     - unused
     - unused
     - usestdlibvars
     - usestdlibvars
   settings:
   settings:

+ 3 - 3
service/go.mod

@@ -13,7 +13,7 @@ require (
 	github.com/MicahParks/keyfunc/v3 v3.8.2
 	github.com/MicahParks/keyfunc/v3 v3.8.2
 	github.com/PaesslerAG/jsonpath v0.1.1
 	github.com/PaesslerAG/jsonpath v0.1.1
 	github.com/alexedwards/argon2id v1.0.0
 	github.com/alexedwards/argon2id v1.0.0
-	github.com/bufbuild/buf v1.74.0
+	github.com/bufbuild/buf v1.75.0
 	github.com/expr-lang/expr v1.17.8
 	github.com/expr-lang/expr v1.17.8
 	github.com/fsnotify/fsnotify v1.10.1
 	github.com/fsnotify/fsnotify v1.10.1
 	github.com/go-viper/mapstructure/v2 v2.5.0
 	github.com/go-viper/mapstructure/v2 v2.5.0
@@ -33,7 +33,7 @@ require (
 	go.akshayshah.org/connectproto v0.6.0
 	go.akshayshah.org/connectproto v0.6.0
 	golang.org/x/oauth2 v0.37.0
 	golang.org/x/oauth2 v0.37.0
 	golang.org/x/sys v0.49.0
 	golang.org/x/sys v0.49.0
-	google.golang.org/protobuf v1.36.12
+	google.golang.org/protobuf v1.36.13-0.20261016172727-e9ee2a76ab8e
 	gopkg.in/yaml.v3 v3.0.1
 	gopkg.in/yaml.v3 v3.0.1
 )
 )
 
 
@@ -97,7 +97,7 @@ require (
 	github.com/bombsimon/wsl/v5 v5.9.0 // indirect
 	github.com/bombsimon/wsl/v5 v5.9.0 // indirect
 	github.com/breml/bidichk v0.3.4 // indirect
 	github.com/breml/bidichk v0.3.4 // indirect
 	github.com/breml/errchkjson v0.4.2 // indirect
 	github.com/breml/errchkjson v0.4.2 // indirect
-	github.com/bufbuild/protocompile v0.14.2-0.20261013160345-b79bfe2f85df // indirect
+	github.com/bufbuild/protocompile v0.14.2-0.20261020145055-56248e843b50 // indirect
 	github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b // indirect
 	github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b // indirect
 	github.com/butuzov/ireturn v0.4.1 // indirect
 	github.com/butuzov/ireturn v0.4.1 // indirect
 	github.com/butuzov/mirror v1.3.3 // indirect
 	github.com/butuzov/mirror v1.3.3 // indirect

+ 6 - 6
service/go.sum

@@ -138,10 +138,10 @@ github.com/breml/errchkjson v0.4.2 h1:4o23TrT/nju9uIxfV/WRja96u8X/1fKHDZXGB1acj2
 github.com/breml/errchkjson v0.4.2/go.mod h1:Wq62jvsaH+Eb1F2LJQlBenxwDhYgXe9wLMDuCe7HOVo=
 github.com/breml/errchkjson v0.4.2/go.mod h1:Wq62jvsaH+Eb1F2LJQlBenxwDhYgXe9wLMDuCe7HOVo=
 github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
 github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4=
 github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
 github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs=
-github.com/bufbuild/buf v1.74.0 h1:zk0IvEFZpUBWKUh9Hyt6vKtIu7uoSjgU8le9sysJYhI=
-github.com/bufbuild/buf v1.74.0/go.mod h1:ovhzeVGqUqy4vuumyWvsUbAq0bqdJHptYKRktjiIqJA=
-github.com/bufbuild/protocompile v0.14.2-0.20261013160345-b79bfe2f85df h1:DRUi+xCmK/geLBx7WYsdWRVsg7OmyZPytgEA9koxI0c=
-github.com/bufbuild/protocompile v0.14.2-0.20261013160345-b79bfe2f85df/go.mod h1:bX3ObJfML+aki7PJevkWLSlOfEZ8EnNsfCz0hiYkhiI=
+github.com/bufbuild/buf v1.75.0 h1:FvUEktoR5wduFOq1j+HmOswL0PM4pjDivLkJ1iwRztg=
+github.com/bufbuild/buf v1.75.0/go.mod h1:PMlP1QzE1ddPbaL3vW5cac0WKAvvPPipBTu/1QHvxOI=
+github.com/bufbuild/protocompile v0.14.2-0.20261020145055-56248e843b50 h1:JjQ4WXOPRj7b404ouvTJNdYHi2Ib4Bfl5VoSBluKbGs=
+github.com/bufbuild/protocompile v0.14.2-0.20261020145055-56248e843b50/go.mod h1:mcyF/iWO0TSVh4LvVBH5gJO1N9pQyooNy+XR4tNlbO0=
 github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b h1:b7wvo9ZhjLzCp7tGbOUMvgtYTnd33zGSAmMxcdxMnhQ=
 github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b h1:b7wvo9ZhjLzCp7tGbOUMvgtYTnd33zGSAmMxcdxMnhQ=
 github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ=
 github.com/bufbuild/protoplugin v0.0.0-20260414125817-25d1d281b46b/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ=
 github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I=
 github.com/butuzov/ireturn v0.4.1 h1:vWb3NO4t77iku/sjCQ/2pHTQeOmxEhjIriJqRLg1Y+I=
@@ -758,8 +758,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b h1:
 google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b/go.mod h1:9N4vtjEwvgQKEdlFVQO0M44ErhecQ9DCSQfGoZ51M6w=
 google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b/go.mod h1:9N4vtjEwvgQKEdlFVQO0M44ErhecQ9DCSQfGoZ51M6w=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b h1:6WNWHZk91dp4gZ25642OALkuG1UVNZbwENJD19DnB8k=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20261019180522-ce908d29299b/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
-google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
-google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+google.golang.org/protobuf v1.36.13-0.20261016172727-e9ee2a76ab8e h1:TU4XCsANCrpOpeS6NA+LhCA6H12DfxF2gIcYBrWcdwc=
+google.golang.org/protobuf v1.36.13-0.20261016172727-e9ee2a76ab8e/go.mod h1:X7eSFOSeyjM8zqq7vd+xpgLluojDTuH3nwq1zr6OCKg=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=

+ 39 - 11
service/internal/api/api.go

@@ -8,6 +8,7 @@ import (
 	"net/http"
 	"net/http"
 	"os"
 	"os"
 	"path"
 	"path"
+	"slices"
 	"sort"
 	"sort"
 	"strings"
 	"strings"
 	"sync"
 	"sync"
@@ -806,8 +807,8 @@ func (api *oliveTinAPI) buildActionLogsResponse(filtered []*executor.InternalLog
 	startIdx, endIdx := calculateReversedIndices(page, len(filtered))
 	startIdx, endIdx := calculateReversedIndices(page, len(filtered))
 	ret := &apiv1.GetActionLogsResponse{}
 	ret := &apiv1.GetActionLogsResponse{}
 	chunk := filtered[int(startIdx):int(endIdx)]
 	chunk := filtered[int(startIdx):int(endIdx)]
-	for i := len(chunk) - 1; i >= 0; i-- {
-		ret.Logs = append(ret.Logs, api.internalLogEntryToPb(chunk[i], user))
+	for _, c := range slices.Backward(chunk) {
+		ret.Logs = append(ret.Logs, api.internalLogEntryToPb(c, user))
 	}
 	}
 	ret.CountRemaining = page.start
 	ret.CountRemaining = page.start
 	ret.PageSize = page.size
 	ret.PageSize = page.size
@@ -1118,22 +1119,49 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
 		"authenticatedUser": user.Username,
 		"authenticatedUser": user.Username,
 	}).Debugf("EventStream: client connected")
 	}).Debugf("EventStream: client connected")
 
 
+	// Release the slot on every exit path. removeClient is idempotent, so this
+	// is safe when a broadcast or the heartbeat goroutine already removed us.
+	defer api.removeClient(client)
+
 	go api.sendEventStreamHeartbeats(client)
 	go api.sendEventStreamHeartbeats(client)
 
 
-	// loop over client channel and send events to connectedClient
-	for msg := range client.channel {
+	api.forwardEventsToClient(ctx.Done(), client, srv)
+
+	log.Infof("EventStream: client disconnected")
+
+	return nil
+}
+
+// forwardEventsToClient sends queued events to the stream until the client goes
+// away. Without watching done, a client that disconnected was only noticed once
+// a later Send failed, which net/http's buffered writer reports several
+// heartbeats late (or not at all while the TCP peer keeps ACKing), so its slot
+// in maxEventStreamClients stayed taken.
+func (api *oliveTinAPI) forwardEventsToClient(done <-chan struct{}, client *streamingClient, srv *connect.ServerStream[apiv1.EventStreamResponse]) {
+	for {
+		msg, ok := nextEvent(done, client.channel)
+		if !ok {
+			return
+		}
+
 		log.Debugf("Sending event to client: %v", msg)
 		log.Debugf("Sending event to client: %v", msg)
+
 		if err := srv.Send(msg); err != nil {
 		if err := srv.Send(msg); err != nil {
-			log.Errorf("Error sending event to client: %v", err)
-			// Remove disconnected client from the list
-			api.removeClient(client)
-			break
+			log.Warnf("Error sending event to client: %v", err)
+			return
 		}
 		}
 	}
 	}
+}
 
 
-	log.Infof("EventStream: client disconnected")
-
-	return nil
+// nextEvent waits for the next queued event. ok is false once the request is
+// done (the client went away) or the channel was closed by removeClient.
+func nextEvent(done <-chan struct{}, events <-chan *apiv1.EventStreamResponse) (*apiv1.EventStreamResponse, bool) {
+	select {
+	case <-done:
+		return nil, false
+	case msg, ok := <-events:
+		return msg, ok
+	}
 }
 }
 
 
 func (api *oliveTinAPI) registerStreamingClient(client *streamingClient) error {
 func (api *oliveTinAPI) registerStreamingClient(client *streamingClient) error {

+ 1 - 4
service/internal/api/api_entities_list.go

@@ -156,10 +156,7 @@ func paginateEntityInstances(instances []*apiv1.Entity, page, pageSize int32) []
 		return []*apiv1.Entity{}
 		return []*apiv1.Entity{}
 	}
 	}
 
 
-	end := start + int64(pageSize)
-	if end > count {
-		end = count
-	}
+	end := min(start+int64(pageSize), count)
 
 
 	return instances[int(start):int(end)]
 	return instances[int(start):int(end)]
 }
 }

+ 12 - 8
service/internal/api/api_entity_related_actions_test.go

@@ -92,9 +92,11 @@ func buildRelatedActionsTestConfig(t *testing.T) (*config.Config, *authpublic.Au
 	return cfg, lowUser, adminUser
 	return cfg, lowUser, adminUser
 }
 }
 
 
-func getEntityRelatedActionTitles(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
+func getEntityRelatedActionTitles(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityKey string) []string {
 	t.Helper()
 	t.Helper()
 
 
+	entityType := "host"
+
 	entity, ok := entities.GetEntityInstances(entityType)[entityKey]
 	entity, ok := entities.GetEntityInstances(entityType)[entityKey]
 	require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
 	require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
 
 
@@ -108,9 +110,11 @@ func getEntityRelatedActionTitles(t *testing.T, api *oliveTinAPI, user *authpubl
 	return titles
 	return titles
 }
 }
 
 
-func getEntityRelatedBindingIDs(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityType, entityKey string) []string {
+func getEntityRelatedBindingIDs(t *testing.T, api *oliveTinAPI, user *authpublic.AuthenticatedUser, entityKey string) []string {
 	t.Helper()
 	t.Helper()
 
 
+	entityType := "host"
+
 	entity, ok := entities.GetEntityInstances(entityType)[entityKey]
 	entity, ok := entities.GetEntityInstances(entityType)[entityKey]
 	require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
 	require.True(t, ok, "entity %s/%s must exist", entityType, entityKey)
 
 
@@ -132,10 +136,10 @@ func TestGetEntityRelatedActionsDeniesRestrictedView(t *testing.T) {
 	ex.RebuildActionMap()
 	ex.RebuildActionMap()
 	api := newServer(ex)
 	api := newServer(ex)
 
 
-	ids := getEntityRelatedBindingIDs(t, api, lowUser, "host", "0")
+	ids := getEntityRelatedBindingIDs(t, api, lowUser, "0")
 	assert.Empty(t, ids)
 	assert.Empty(t, ids)
 
 
-	titles := getEntityRelatedActionTitles(t, api, lowUser, "host", "0")
+	titles := getEntityRelatedActionTitles(t, api, lowUser, "0")
 	assert.Empty(t, titles)
 	assert.Empty(t, titles)
 }
 }
 
 
@@ -147,7 +151,7 @@ func TestGetEntityRelatedActionsAllowsAdminView(t *testing.T) {
 	ex.RebuildActionMap()
 	ex.RebuildActionMap()
 	api := newServer(ex)
 	api := newServer(ex)
 
 
-	titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
+	titles := getEntityRelatedActionTitles(t, api, adminUser, "0")
 	assert.Contains(t, titles, "Run Automation Playbook")
 	assert.Contains(t, titles, "Run Automation Playbook")
 	assert.Contains(t, titles, "Public Host Action")
 	assert.Contains(t, titles, "Public Host Action")
 	assert.Contains(t, titles, "Secret Entity Action")
 	assert.Contains(t, titles, "Secret Entity Action")
@@ -161,7 +165,7 @@ func TestGetEntityRelatedActionsExcludesHiddenActions(t *testing.T) {
 	ex.RebuildActionMap()
 	ex.RebuildActionMap()
 	api := newServer(ex)
 	api := newServer(ex)
 
 
-	titles := getEntityRelatedActionTitles(t, api, adminUser, "host", "0")
+	titles := getEntityRelatedActionTitles(t, api, adminUser, "0")
 	assert.NotContains(t, titles, "Hidden Host Action")
 	assert.NotContains(t, titles, "Hidden Host Action")
 }
 }
 
 
@@ -179,11 +183,11 @@ func TestGetEntityRelatedActionsEntityBoundMatchesInstanceOnly(t *testing.T) {
 	api := newServer(ex)
 	api := newServer(ex)
 	user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
 	user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
 
 
-	titlesHost0 := getEntityRelatedActionTitles(t, api, user, "host", "0")
+	titlesHost0 := getEntityRelatedActionTitles(t, api, user, "0")
 	assert.Len(t, titlesHost0, 1)
 	assert.Len(t, titlesHost0, 1)
 	assert.Equal(t, "stuffbox Wake", titlesHost0[0])
 	assert.Equal(t, "stuffbox Wake", titlesHost0[0])
 
 
-	titlesHost1 := getEntityRelatedActionTitles(t, api, user, "host", "1")
+	titlesHost1 := getEntityRelatedActionTitles(t, api, user, "1")
 	assert.Len(t, titlesHost1, 1)
 	assert.Len(t, titlesHost1, 1)
 	assert.Equal(t, "lurker Wake", titlesHost1[0])
 	assert.Equal(t, "lurker Wake", titlesHost1[0])
 }
 }

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

@@ -190,7 +190,7 @@ func TestBuildSearchHintsCapsActionsAndEntitiesPerType(t *testing.T) {
 		entities.ClearEntitiesOfType("cap_container")
 		entities.ClearEntitiesOfType("cap_container")
 	})
 	})
 
 
-	for entityIndex := 0; entityIndex < maxSearchHintEntitiesPerType+5; entityIndex++ {
+	for entityIndex := range maxSearchHintEntitiesPerType + 5 {
 		entities.AddEntity("cap_host", fmt.Sprintf("%03d", entityIndex), map[string]any{"name": fmt.Sprintf("host-%03d", entityIndex)})
 		entities.AddEntity("cap_host", fmt.Sprintf("%03d", entityIndex), map[string]any{"name": fmt.Sprintf("host-%03d", entityIndex)})
 		entities.AddEntity("cap_container", fmt.Sprintf("%03d", entityIndex), map[string]any{"name": fmt.Sprintf("ctr-%03d", entityIndex)})
 		entities.AddEntity("cap_container", fmt.Sprintf("%03d", entityIndex), map[string]any{"name": fmt.Sprintf("ctr-%03d", entityIndex)})
 	}
 	}
@@ -201,7 +201,7 @@ func TestBuildSearchHintsCapsActionsAndEntitiesPerType(t *testing.T) {
 		{Name: "cap_container", File: "cap_container.yaml"},
 		{Name: "cap_container", File: "cap_container.yaml"},
 	}
 	}
 	cfg.Actions = make([]*config.Action, 0, maxSearchHintActions+5)
 	cfg.Actions = make([]*config.Action, 0, maxSearchHintActions+5)
-	for actionIndex := 0; actionIndex < maxSearchHintActions+5; actionIndex++ {
+	for actionIndex := range maxSearchHintActions + 5 {
 		cfg.Actions = append(cfg.Actions, &config.Action{
 		cfg.Actions = append(cfg.Actions, &config.Action{
 			ID:    fmt.Sprintf("action-%03d", actionIndex),
 			ID:    fmt.Sprintf("action-%03d", actionIndex),
 			Title: fmt.Sprintf("Action %03d", actionIndex),
 			Title: fmt.Sprintf("Action %03d", actionIndex),
@@ -240,7 +240,7 @@ func TestBuildSearchHintsPrefersNonEntityActions(t *testing.T) {
 		entities.ClearEntitiesOfType("host")
 		entities.ClearEntitiesOfType("host")
 	})
 	})
 
 
-	for i := 0; i < 10; i++ {
+	for i := range 10 {
 		entities.AddEntity("host", fmt.Sprintf("%d", i), map[string]any{"name": fmt.Sprintf("host-%d", i)})
 		entities.AddEntity("host", fmt.Sprintf("%d", i), map[string]any{"name": fmt.Sprintf("host-%d", i)})
 	}
 	}
 
 

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

@@ -14,10 +14,10 @@ import (
 	"github.com/OliveTin/OliveTin/internal/executor"
 	"github.com/OliveTin/OliveTin/internal/executor"
 )
 )
 
 
-func argumentAction(title, shell string, args []config.ActionArgument) *config.Action {
+func argumentAction(title string, args []config.ActionArgument) *config.Action {
 	return &config.Action{
 	return &config.Action{
 		Title:         title,
 		Title:         title,
-		Shell:         shell,
+		Shell:         "echo {{ host }}",
 		MaxConcurrent: 1,
 		MaxConcurrent: 1,
 		Arguments:     args,
 		Arguments:     args,
 	}
 	}
@@ -75,7 +75,7 @@ func waitForLogJustification(t *testing.T, ex *executor.Executor, trackingID, ex
 func TestExecutionStatusIncludesStoredArguments(t *testing.T) {
 func TestExecutionStatusIncludesStoredArguments(t *testing.T) {
 	cfg := config.DefaultConfig()
 	cfg := config.DefaultConfig()
 	cfg.Actions = []*config.Action{
 	cfg.Actions = []*config.Action{
-		argumentAction("Ping host with stored args", "echo {{ host }}", []config.ActionArgument{
+		argumentAction("Ping host with stored args", []config.ActionArgument{
 			{Name: "host", Type: "ascii_identifier"},
 			{Name: "host", Type: "ascii_identifier"},
 		}),
 		}),
 	}
 	}
@@ -159,7 +159,7 @@ func TestExecutionStatusOmitsPasswordArguments(t *testing.T) {
 func TestRestartActionReusesStoredArguments(t *testing.T) {
 func TestRestartActionReusesStoredArguments(t *testing.T) {
 	cfg := config.DefaultConfig()
 	cfg := config.DefaultConfig()
 	cfg.Actions = []*config.Action{
 	cfg.Actions = []*config.Action{
-		argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
+		argumentAction("Ping host", []config.ActionArgument{
 			{Name: "host", Type: "ascii_identifier"},
 			{Name: "host", Type: "ascii_identifier"},
 		}),
 		}),
 	}
 	}
@@ -236,7 +236,7 @@ func TestRestartActionRejectsIncompleteStoredArguments(t *testing.T) {
 func TestRestartActionRejectsMissingRequiredStoredArguments(t *testing.T) {
 func TestRestartActionRejectsMissingRequiredStoredArguments(t *testing.T) {
 	cfg := config.DefaultConfig()
 	cfg := config.DefaultConfig()
 	cfg.Actions = []*config.Action{
 	cfg.Actions = []*config.Action{
-		argumentAction("Ping host - reject missing required stored arg", "echo {{ host }}", []config.ActionArgument{
+		argumentAction("Ping host - reject missing required stored arg", []config.ActionArgument{
 			{Name: "host", Type: "ascii_identifier"},
 			{Name: "host", Type: "ascii_identifier"},
 		}),
 		}),
 	}
 	}
@@ -368,7 +368,7 @@ func TestRestartActionReusesStoredJustificationViaStartActionPath(t *testing.T)
 func TestGetLogsIncludesStoredArguments(t *testing.T) {
 func TestGetLogsIncludesStoredArguments(t *testing.T) {
 	cfg := config.DefaultConfig()
 	cfg := config.DefaultConfig()
 	cfg.Actions = []*config.Action{
 	cfg.Actions = []*config.Action{
-		argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{
+		argumentAction("Ping host", []config.ActionArgument{
 			{Name: "host", Type: "ascii_identifier"},
 			{Name: "host", Type: "ascii_identifier"},
 		}),
 		}),
 	}
 	}

+ 54 - 1
service/internal/api/api_test.go

@@ -864,7 +864,7 @@ func TestRegisterStreamingClientEnforcesLimit(t *testing.T) {
 	user := &authpublic.AuthenticatedUser{Username: "limit-test"}
 	user := &authpublic.AuthenticatedUser{Username: "limit-test"}
 
 
 	clients := make([]*streamingClient, 0, maxEventStreamClients)
 	clients := make([]*streamingClient, 0, maxEventStreamClients)
-	for i := 0; i < maxEventStreamClients; i++ {
+	for range maxEventStreamClients {
 		client := &streamingClient{
 		client := &streamingClient{
 			channel:           make(chan *apiv1.EventStreamResponse, 1),
 			channel:           make(chan *apiv1.EventStreamResponse, 1),
 			AuthenticatedUser: user,
 			AuthenticatedUser: user,
@@ -897,6 +897,59 @@ func TestRegisterStreamingClientEnforcesLimit(t *testing.T) {
 	api.removeClient(overflow)
 	api.removeClient(overflow)
 }
 }
 
 
+// TestEventStreamReleasesSlotWhenClientDisconnects fills every EventStream slot,
+// disconnects all those clients and expects the slots to be released right
+// away, so a new client is accepted instead of being rejected with
+// "concurrent client limit reached".
+func TestEventStreamReleasesSlotWhenClientDisconnects(t *testing.T) {
+	cfg := config.DefaultConfig()
+	ex := executor.DefaultExecutor(cfg)
+	ex.RebuildActionMap()
+
+	ts, client := getNewTestServerAndClientWithExecutor(cfg, ex)
+	defer ts.Close()
+
+	server := ensureExecutorListener(ex)
+
+	cancels := make([]context.CancelFunc, 0, maxEventStreamClients)
+	for range maxEventStreamClients {
+		cancels = append(cancels, openEventStreamAndReadHeartbeat(t, client))
+	}
+	require.Equal(t, maxEventStreamClients, countStreamingClients(server))
+
+	for _, cancel := range cancels {
+		cancel()
+	}
+
+	// The next heartbeat is 10s away, so anything under that proves the
+	// disconnect itself released the slots, not a later failed write.
+	require.Eventually(t, func() bool {
+		return countStreamingClients(server) == 0
+	}, 2*time.Second, 10*time.Millisecond, "disconnected EventStream clients still hold their slots")
+
+	cancel := openEventStreamAndReadHeartbeat(t, client)
+	cancel()
+}
+
+func openEventStreamAndReadHeartbeat(t *testing.T, client apiv1connect.OliveTinApiServiceClient) context.CancelFunc {
+	t.Helper()
+
+	ctx, cancel := context.WithCancel(context.Background())
+	stream, err := client.EventStream(ctx, connect.NewRequest(&apiv1.EventStreamRequest{}))
+	require.NoError(t, err)
+	require.True(t, stream.Receive(), "expected an initial heartbeat, got error: %v", stream.Err())
+	require.NotNil(t, stream.Msg().GetHeartbeat())
+
+	return cancel
+}
+
+func countStreamingClients(api *oliveTinAPI) int {
+	api.streamingClientsMutex.RLock()
+	defer api.streamingClientsMutex.RUnlock()
+
+	return len(api.streamingClients)
+}
+
 func addEventStreamTestClients(t *testing.T, api *oliveTinAPI, lowUser, adminUser *authpublic.AuthenticatedUser) (*streamingClient, *streamingClient) {
 func addEventStreamTestClients(t *testing.T, api *oliveTinAPI, lowUser, adminUser *authpublic.AuthenticatedUser) (*streamingClient, *streamingClient) {
 	t.Helper()
 	t.Helper()
 	clientLow := &streamingClient{
 	clientLow := &streamingClient{

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

@@ -100,7 +100,7 @@ func TestRootDashboardEntriesIncludeCategory(t *testing.T) {
 	entries := api.buildRootDashboardEntries(user, cfg.Dashboards)
 	entries := api.buildRootDashboardEntries(user, cfg.Dashboards)
 	require.Len(t, entries, 4)
 	require.Len(t, entries, 4)
 	assert.Equal(t, []string{"Misc Tools", "My Servers", "Status Board", "My Containers"}, rootDashboardTitles(entries))
 	assert.Equal(t, []string{"Misc Tools", "My Servers", "Status Board", "My Containers"}, rootDashboardTitles(entries))
-	assert.Equal(t, "", entries[0].Category)
+	assert.Empty(t, entries[0].Category)
 	assert.Equal(t, "Infrastructure", entries[1].Category)
 	assert.Equal(t, "Infrastructure", entries[1].Category)
 	assert.Equal(t, "Monitoring", entries[2].Category)
 	assert.Equal(t, "Monitoring", entries[2].Category)
 	assert.Equal(t, "Infrastructure", entries[3].Category)
 	assert.Equal(t, "Infrastructure", entries[3].Category)

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

@@ -28,14 +28,14 @@ func TestBuildDefaultDashboardConcurrentWithRebuild(t *testing.T) {
 
 
 	go func() {
 	go func() {
 		defer wg.Done()
 		defer wg.Done()
-		for i := 0; i < 200; i++ {
+		for range 200 {
 			ex.RebuildActionMap()
 			ex.RebuildActionMap()
 		}
 		}
 	}()
 	}()
 
 
 	go func() {
 	go func() {
 		defer wg.Done()
 		defer wg.Done()
-		for i := 0; i < 200; i++ {
+		for range 200 {
 			buildDefaultDashboard(rr)
 			buildDefaultDashboard(rr)
 		}
 		}
 	}()
 	}()

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

@@ -26,7 +26,7 @@ func (u *AuthenticatedUser) parseUsergroupLine(sep string) []string {
 	ret := []string{}
 	ret := []string{}
 
 
 	if sep != "" {
 	if sep != "" {
-		for _, v := range strings.Split(u.UsergroupLine, sep) {
+		for v := range strings.SplitSeq(u.UsergroupLine, sep) {
 			trimmed := strings.TrimSpace(v)
 			trimmed := strings.TrimSpace(v)
 
 
 			if trimmed != "" {
 			if trimmed != "" {

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

@@ -43,13 +43,13 @@ func TestGetGroupFieldString(t *testing.T) {
 func TestGetGroupFieldMissing(t *testing.T) {
 func TestGetGroupFieldMissing(t *testing.T) {
 	data := map[string]any{}
 	data := map[string]any{}
 
 
-	assert.Equal(t, "", getGroupField(data, "olivetin_group", ""))
+	assert.Empty(t, getGroupField(data, "olivetin_group", ""))
 }
 }
 
 
 func TestGetGroupFieldEmptyFieldName(t *testing.T) {
 func TestGetGroupFieldEmptyFieldName(t *testing.T) {
 	data := map[string]any{"olivetin_group": "admins"}
 	data := map[string]any{"olivetin_group": "admins"}
 
 
-	assert.Equal(t, "", getGroupField(data, "", ""))
+	assert.Empty(t, getGroupField(data, "", ""))
 }
 }
 
 
 func TestGetGroupFieldArrayDefaultSeparator(t *testing.T) {
 func TestGetGroupFieldArrayDefaultSeparator(t *testing.T) {
@@ -73,13 +73,13 @@ func TestGetGroupFieldArraySkipsNonStringElements(t *testing.T) {
 func TestGetGroupFieldArrayAllNonStringElements(t *testing.T) {
 func TestGetGroupFieldArrayAllNonStringElements(t *testing.T) {
 	data := map[string]any{"groups": []any{float64(1), true}}
 	data := map[string]any{"groups": []any{float64(1), true}}
 
 
-	assert.Equal(t, "", getGroupField(data, "groups", ""))
+	assert.Empty(t, getGroupField(data, "groups", ""))
 }
 }
 
 
 func TestGetGroupFieldNotStringOrArray(t *testing.T) {
 func TestGetGroupFieldNotStringOrArray(t *testing.T) {
 	data := map[string]any{"groups": map[string]any{"nested": "value"}}
 	data := map[string]any{"groups": map[string]any{"nested": "value"}}
 
 
-	assert.Equal(t, "", getGroupField(data, "groups", ""))
+	assert.Empty(t, getGroupField(data, "groups", ""))
 }
 }
 
 
 func TestGetUserInfoWithArrayGroupsClaim(t *testing.T) {
 func TestGetUserInfoWithArrayGroupsClaim(t *testing.T) {
@@ -141,7 +141,7 @@ func TestHandleOAuthLoginRejectsWhenStateMapFull(t *testing.T) {
 
 
 	h := NewOAuth2Handler(cfg)
 	h := NewOAuth2Handler(cfg)
 	h.registeredStates = make(map[string]*oauth2State, oauthStateMaxEntries)
 	h.registeredStates = make(map[string]*oauth2State, oauthStateMaxEntries)
-	for i := 0; i < oauthStateMaxEntries; i++ {
+	for i := range oauthStateMaxEntries {
 		h.registeredStates[strconv.Itoa(i)] = &oauth2State{
 		h.registeredStates[strconv.Itoa(i)] = &oauth2State{
 			providerConfig: &oauth2.Config{},
 			providerConfig: &oauth2.Config{},
 			providerName:   "test",
 			providerName:   "test",
@@ -155,5 +155,5 @@ func TestHandleOAuthLoginRejectsWhenStateMapFull(t *testing.T) {
 	h.HandleOAuthLogin(rec, req)
 	h.HandleOAuthLogin(rec, req)
 
 
 	assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
 	assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
-	assert.Equal(t, oauthStateMaxEntries, len(h.registeredStates))
+	assert.Len(t, h.registeredStates, oauthStateMaxEntries)
 }
 }

+ 8 - 8
service/internal/config/config_reloader_user_test.go

@@ -42,7 +42,7 @@ actions:
 	// Load config using koanf
 	// Load config using koanf
 	k := koanf.New(".")
 	k := koanf.New(".")
 	err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
 	err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
-	assert.NoError(t, err, "Should load config file")
+	require.NoError(t, err, "Should load config file")
 
 
 	// Create config struct and load it
 	// Create config struct and load it
 	cfg := &Config{}
 	cfg := &Config{}
@@ -50,7 +50,7 @@ actions:
 
 
 	// Test that authLocalUsers was loaded correctly
 	// Test that authLocalUsers was loaded correctly
 	assert.True(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be enabled")
 	assert.True(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be enabled")
-	assert.Equal(t, 2, len(cfg.AuthLocalUsers.Users), "Should load 2 users")
+	assert.Len(t, cfg.AuthLocalUsers.Users, 2, "Should load 2 users")
 
 
 	// Test individual users
 	// Test individual users
 	user1 := cfg.FindUserByUsername("testuser1")
 	user1 := cfg.FindUserByUsername("testuser1")
@@ -91,13 +91,13 @@ actions:
 
 
 	k := koanf.New(".")
 	k := koanf.New(".")
 	err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
 	err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
-	assert.NoError(t, err, "Should load config file")
+	require.NoError(t, err, "Should load config file")
 
 
 	cfg := &Config{}
 	cfg := &Config{}
 	AppendSource(cfg, k, tmpFile.Name())
 	AppendSource(cfg, k, tmpFile.Name())
 
 
 	assert.True(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be enabled")
 	assert.True(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be enabled")
-	assert.Equal(t, 0, len(cfg.AuthLocalUsers.Users), "Should have 0 users")
+	assert.Empty(t, cfg.AuthLocalUsers.Users, "Should have 0 users")
 	assert.Nil(t, cfg.FindUserByUsername("anyuser"), "Should return nil for any user")
 	assert.Nil(t, cfg.FindUserByUsername("anyuser"), "Should return nil for any user")
 }
 }
 
 
@@ -126,13 +126,13 @@ actions:
 
 
 	k := koanf.New(".")
 	k := koanf.New(".")
 	err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
 	err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
-	assert.NoError(t, err, "Should load config file")
+	require.NoError(t, err, "Should load config file")
 
 
 	cfg := &Config{}
 	cfg := &Config{}
 	AppendSource(cfg, k, tmpFile.Name())
 	AppendSource(cfg, k, tmpFile.Name())
 
 
 	assert.False(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be disabled")
 	assert.False(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be disabled")
-	assert.Equal(t, 1, len(cfg.AuthLocalUsers.Users), "Should still load users even when disabled")
+	assert.Len(t, cfg.AuthLocalUsers.Users, 1, "Should still load users even when disabled")
 
 
 	// User should still be findable even when auth is disabled
 	// User should still be findable even when auth is disabled
 	user := cfg.FindUserByUsername("testuser")
 	user := cfg.FindUserByUsername("testuser")
@@ -157,13 +157,13 @@ actions:
 
 
 	k := koanf.New(".")
 	k := koanf.New(".")
 	err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
 	err = k.Load(file.Provider(tmpFile.Name()), yaml.Parser())
-	assert.NoError(t, err, "Should load config file")
+	require.NoError(t, err, "Should load config file")
 
 
 	cfg := &Config{}
 	cfg := &Config{}
 	AppendSource(cfg, k, tmpFile.Name())
 	AppendSource(cfg, k, tmpFile.Name())
 
 
 	// Should have default values
 	// Should have default values
 	assert.False(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be disabled by default")
 	assert.False(t, cfg.AuthLocalUsers.Enabled, "AuthLocalUsers should be disabled by default")
-	assert.Equal(t, 0, len(cfg.AuthLocalUsers.Users), "Should have 0 users by default")
+	assert.Empty(t, cfg.AuthLocalUsers.Users, "Should have 0 users by default")
 	assert.Nil(t, cfg.FindUserByUsername("anyuser"), "Should return nil for any user")
 	assert.Nil(t, cfg.FindUserByUsername("anyuser"), "Should return nil for any user")
 }
 }

+ 4 - 3
service/internal/config/entity_argument_choices_test.go

@@ -4,10 +4,11 @@ import (
 	"testing"
 	"testing"
 
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 )
 
 
 func TestValidateEntityArgumentChoicesForArgument(t *testing.T) {
 func TestValidateEntityArgumentChoicesForArgument(t *testing.T) {
-	assert.NoError(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
+	require.NoError(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
 		Name:   "target",
 		Name:   "target",
 		Entity: "servers",
 		Entity: "servers",
 		Choices: []ActionArgumentChoice{
 		Choices: []ActionArgumentChoice{
@@ -15,7 +16,7 @@ func TestValidateEntityArgumentChoicesForArgument(t *testing.T) {
 		},
 		},
 	}))
 	}))
 
 
-	assert.Error(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
+	require.Error(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
 		Name:   "target",
 		Name:   "target",
 		Entity: "servers",
 		Entity: "servers",
 		Choices: []ActionArgumentChoice{
 		Choices: []ActionArgumentChoice{
@@ -24,7 +25,7 @@ func TestValidateEntityArgumentChoicesForArgument(t *testing.T) {
 		},
 		},
 	}))
 	}))
 
 
-	assert.Error(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
+	require.Error(t, validateEntityArgumentChoicesForArgument("Reboot", ActionArgument{
 		Name:    "target",
 		Name:    "target",
 		Entity:  "servers",
 		Entity:  "servers",
 		Choices: nil,
 		Choices: nil,

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

@@ -1,5 +1,7 @@
 package config
 package config
 
 
+import "slices"
+
 import "sync"
 import "sync"
 
 
 var (
 var (
@@ -23,10 +25,8 @@ func RecordLoadedSource(path string) {
 	loadedSourcesMu.Lock()
 	loadedSourcesMu.Lock()
 	defer loadedSourcesMu.Unlock()
 	defer loadedSourcesMu.Unlock()
 
 
-	for _, existing := range loadedSources {
-		if existing == path {
-			return
-		}
+	if slices.Contains(loadedSources, path) {
+		return
 	}
 	}
 
 
 	loadedSources = append(loadedSources, path)
 	loadedSources = append(loadedSources, path)

+ 2 - 2
service/internal/entities/entities_test.go

@@ -58,8 +58,8 @@ func TestDisplayNameFieldKey(t *testing.T) {
 	assert.Equal(t, "title", DisplayNameFieldKey(map[string]any{"title": "Car"}))
 	assert.Equal(t, "title", DisplayNameFieldKey(map[string]any{"title": "Car"}))
 	assert.Equal(t, "name", DisplayNameFieldKey(map[string]any{"name": "Car"}))
 	assert.Equal(t, "name", DisplayNameFieldKey(map[string]any{"name": "Car"}))
 	assert.Equal(t, "Name", DisplayNameFieldKey(map[string]any{"Name": "Car"}))
 	assert.Equal(t, "Name", DisplayNameFieldKey(map[string]any{"Name": "Car"}))
-	assert.Equal(t, "", DisplayNameFieldKey(map[string]any{"status": "running"}))
-	assert.Equal(t, "", DisplayNameFieldKey("not a map"))
+	assert.Empty(t, DisplayNameFieldKey(map[string]any{"status": "running"}))
+	assert.Empty(t, DisplayNameFieldKey("not a map"))
 }
 }
 
 
 func TestDisplayNameFieldKey_caseCollisionIsDeterministic(t *testing.T) {
 func TestDisplayNameFieldKey_caseCollisionIsDeterministic(t *testing.T) {

+ 3 - 3
service/internal/executor/executor.go

@@ -1313,8 +1313,8 @@ func substituteShellAfterCompletedEnvRefs(command string) string {
 
 
 func replaceShellAfterEnvRef(command string, pattern *regexp.Regexp, envRef string) string {
 func replaceShellAfterEnvRef(command string, pattern *regexp.Regexp, envRef string) string {
 	matches := pattern.FindAllStringIndex(command, -1)
 	matches := pattern.FindAllStringIndex(command, -1)
-	for i := len(matches) - 1; i >= 0; i-- {
-		start, end := matches[i][0], matches[i][1]
+	for _, match := range slices.Backward(matches) {
+		start, end := match[0], match[1]
 		replacement := `"` + envRef + `"`
 		replacement := `"` + envRef + `"`
 		if shellPosInsideSingleQuotes(command, start) {
 		if shellPosInsideSingleQuotes(command, start) {
 			// Break out of single quotes so the env ref can expand at runtime.
 			// Break out of single quotes so the env ref can expand at runtime.
@@ -1528,7 +1528,7 @@ func sanitizeLogFilename(title string) string {
 	}
 	}
 
 
 	// NUL and other C0 controls plus DEL are invalid or problematic in filenames.
 	// NUL and other C0 controls plus DEL are invalid or problematic in filenames.
-	for i := 0; i < 32; i++ {
+	for i := range 32 {
 		oldnew = append(oldnew, string(rune(i)), "_")
 		oldnew = append(oldnew, string(rune(i)), "_")
 	}
 	}
 	oldnew = append(oldnew, "\x7f", "_")
 	oldnew = append(oldnew, "\x7f", "_")

+ 34 - 34
service/internal/executor/executor_test.go

@@ -158,7 +158,7 @@ func TestArgumentNameCamelCase(t *testing.T) {
 	out, err := parseActionArguments(req)
 	out, err := parseActionArguments(req)
 
 
 	assert.Equal(t, "echo 'Tickling Fred'", out)
 	assert.Equal(t, "echo 'Tickling Fred'", out)
-	assert.Nil(t, err)
+	assert.NoError(t, err)
 }
 }
 
 
 func TestArgumentNameSnakeCase(t *testing.T) {
 func TestArgumentNameSnakeCase(t *testing.T) {
@@ -181,7 +181,7 @@ func TestArgumentNameSnakeCase(t *testing.T) {
 	out, err := parseActionArguments(req)
 	out, err := parseActionArguments(req)
 
 
 	assert.Equal(t, "echo 'Tickling Fred'", out)
 	assert.Equal(t, "echo 'Tickling Fred'", out)
-	assert.Nil(t, err)
+	assert.NoError(t, err)
 }
 }
 
 
 func TestGetLogsEmpty(t *testing.T) {
 func TestGetLogsEmpty(t *testing.T) {
@@ -192,7 +192,7 @@ func TestGetLogsEmpty(t *testing.T) {
 	logs, paging := e.GetLogTrackingIds(0, 10)
 	logs, paging := e.GetLogTrackingIds(0, 10)
 
 
 	assert.NotNil(t, logs, "Logs should not be nil")
 	assert.NotNil(t, logs, "Logs should not be nil")
-	assert.Equal(t, 0, len(logs), "No logs yet")
+	assert.Empty(t, logs, "No logs yet")
 	assert.Equal(t, int64(0), paging.CountRemaining, "There should be no remaining logs")
 	assert.Equal(t, int64(0), paging.CountRemaining, "There should be no remaining logs")
 }
 }
 
 
@@ -212,35 +212,35 @@ func TestGetLogsLessThanPageSize(t *testing.T) {
 
 
 	logEntries, paging := e.GetLogTrackingIds(0, 10)
 	logEntries, paging := e.GetLogTrackingIds(0, 10)
 
 
-	assert.Equal(t, 0, len(logEntries), "There should be 0 logs")
+	assert.Empty(t, logEntries, "There should be 0 logs")
 	assert.Zero(t, paging.CountRemaining, "There should be no remaining logs")
 	assert.Zero(t, paging.CountRemaining, "There should be no remaining logs")
 
 
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
 
 
 	logEntries, paging = e.GetLogTrackingIds(0, 10)
 	logEntries, paging = e.GetLogTrackingIds(0, 10)
 
 
-	assert.Equal(t, 7, len(logEntries), "There should be 7 logs")
+	assert.Len(t, logEntries, 7, "There should be 7 logs")
 	assert.Zero(t, paging.CountRemaining, "There should be no remaining logs")
 	assert.Zero(t, paging.CountRemaining, "There should be no remaining logs")
 
 
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
-	execNewReqAndWait(e, "blat", cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
+	execNewReqAndWait(e, cfg)
 
 
 	logEntries, paging = e.GetLogTrackingIds(0, 10)
 	logEntries, paging = e.GetLogTrackingIds(0, 10)
 
 
-	assert.Equal(t, 10, len(logEntries), "There should be 10 logs")
+	assert.Len(t, logEntries, 10, "There should be 10 logs")
 	assert.Equal(t, int64(2), paging.CountRemaining, "There should be 1 remaining logs")
 	assert.Equal(t, int64(2), paging.CountRemaining, "There should be 1 remaining logs")
 }
 }
 
 
-func execNewReqAndWait(e *Executor, title string, cfg *config.Config) {
+func execNewReqAndWait(e *Executor, cfg *config.Config) {
 	req := &ExecutionRequest{
 	req := &ExecutionRequest{
 		//		ActionTitle: title,
 		//		ActionTitle: title,
 		Cfg: cfg,
 		Cfg: cfg,
@@ -250,7 +250,7 @@ func execNewReqAndWait(e *Executor, title string, cfg *config.Config) {
 	e.RebuildActionMap()
 	e.RebuildActionMap()
 	var action *config.Action
 	var action *config.Action
 	for _, a := range cfg.Actions {
 	for _, a := range cfg.Actions {
-		if a.Title == title {
+		if a.Title == "blat" {
 			action = a
 			action = a
 			break
 			break
 		}
 		}
@@ -288,8 +288,8 @@ func TestUnsetRequiredArgument(t *testing.T) {
 
 
 	out, err := parseActionArguments(req)
 	out, err := parseActionArguments(req)
 
 
-	assert.Equal(t, "", out)
-	assert.NotNil(t, err)
+	assert.Empty(t, out)
+	assert.Error(t, err)
 }
 }
 
 
 func TestUnusedArgumentStillPassesTypeSafetyCheck(t *testing.T) {
 func TestUnusedArgumentStillPassesTypeSafetyCheck(t *testing.T) {
@@ -316,8 +316,8 @@ func TestUnusedArgumentStillPassesTypeSafetyCheck(t *testing.T) {
 
 
 	out, err := parseActionArguments(req)
 	out, err := parseActionArguments(req)
 
 
-	assert.Equal(t, "", out)
-	assert.NotNil(t, err)
+	assert.Empty(t, out)
+	assert.Error(t, err)
 }
 }
 
 
 // https://github.com/OliveTin/OliveTin/issues/564
 // https://github.com/OliveTin/OliveTin/issues/564
@@ -357,7 +357,7 @@ func TestMangleInvalidArgumentValues(t *testing.T) {
 	wg.Wait()
 	wg.Wait()
 
 
 	assert.NotNil(t, req.logEntry, "Log entry should not be nil")
 	assert.NotNil(t, req.logEntry, "Log entry should not be nil")
-	assert.Equal(t, req.logEntry.Output, "The date is: 1990-01-10T12:00:00\n", "Date should be mangled to a valid format")
+	assert.Equal(t, "The date is: 1990-01-10T12:00:00\n", req.logEntry.Output, "Date should be mangled to a valid format")
 
 
 }
 }
 
 
@@ -726,7 +726,7 @@ func TestValidatedSystemArgsMatchesSystemArgumentDefinitions(t *testing.T) {
 
 
 	args, err := validatedSystemArgs(req)
 	args, err := validatedSystemArgs(req)
 
 
-	assert.Nil(t, err)
+	require.NoError(t, err)
 	assert.Len(t, args, len(systemArgumentDefinitions))
 	assert.Len(t, args, len(systemArgumentDefinitions))
 	for _, arg := range systemArgumentDefinitions {
 	for _, arg := range systemArgumentDefinitions {
 		assert.Contains(t, args, arg.Name)
 		assert.Contains(t, args, arg.Name)
@@ -745,7 +745,7 @@ func TestBuildShellAfterArgsOnlyAddsExpectedNonSystemArgs(t *testing.T) {
 
 
 	args, err := buildShellAfterArgs(req)
 	args, err := buildShellAfterArgs(req)
 
 
-	assert.Nil(t, err)
+	require.NoError(t, err)
 	assert.Len(t, args, len(systemArgumentDefinitions)+2)
 	assert.Len(t, args, len(systemArgumentDefinitions)+2)
 	assert.Contains(t, args, "output")
 	assert.Contains(t, args, "output")
 	assert.Contains(t, args, "exitCode")
 	assert.Contains(t, args, "exitCode")
@@ -816,7 +816,7 @@ func TestBuildShellAfterArgsUsesValidatedSystemArgs(t *testing.T) {
 
 
 	args, err := buildShellAfterArgs(req)
 	args, err := buildShellAfterArgs(req)
 
 
-	assert.Nil(t, err)
+	require.NoError(t, err)
 	assert.Equal(t, "alice@example.com", args["ot_username"])
 	assert.Equal(t, "alice@example.com", args["ot_username"])
 	assert.Equal(t, "server-track-456", args["ot_executionTrackingId"])
 	assert.Equal(t, "server-track-456", args["ot_executionTrackingId"])
 	assert.Equal(t, "hello", args["output"])
 	assert.Equal(t, "hello", args["output"])
@@ -836,7 +836,7 @@ func TestBuildShellAfterArgsFailsWhenSystemArgIsInvalid(t *testing.T) {
 	args, err := buildShellAfterArgs(req)
 	args, err := buildShellAfterArgs(req)
 
 
 	assert.Nil(t, args)
 	assert.Nil(t, args)
-	assert.NotNil(t, err)
+	require.Error(t, err)
 	assert.Contains(t, err.Error(), `system argument "ot_username" failed validation`)
 	assert.Contains(t, err.Error(), `system argument "ot_username" failed validation`)
 }
 }
 
 
@@ -879,7 +879,7 @@ func TestTriggerExecutesTriggeredAction(t *testing.T) {
 	wg.Wait()
 	wg.Wait()
 
 
 	var got []string
 	var got []string
-	for i := 0; i < 2; i++ {
+	for i := range 2 {
 		select {
 		select {
 		case title := <-finishedTitles:
 		case title := <-finishedTitles:
 			got = append(got, title)
 			got = append(got, title)
@@ -995,15 +995,15 @@ func TestStepSaveLogSanitizesSlashInTitle(t *testing.T) {
 	assert.FileExists(t, outputPath)
 	assert.FileExists(t, outputPath)
 
 
 	resultsEntries, err := os.ReadDir(resultsDir)
 	resultsEntries, err := os.ReadDir(resultsDir)
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	assert.Len(t, resultsEntries, 1, "results file must be flat under resultsDirectory, not a subdirectory")
 	assert.Len(t, resultsEntries, 1, "results file must be flat under resultsDirectory, not a subdirectory")
 
 
 	data, err := os.ReadFile(resultsPath)
 	data, err := os.ReadFile(resultsPath)
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	assert.Contains(t, string(data), title, "YAML content keeps the original action title")
 	assert.Contains(t, string(data), title, "YAML content keeps the original action title")
 
 
 	output, err := os.ReadFile(outputPath)
 	output, err := os.ReadFile(outputPath)
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	assert.Equal(t, "report ok", string(output))
 	assert.Equal(t, "report ok", string(output))
 }
 }
 
 
@@ -1071,7 +1071,7 @@ func TestStepSaveLogSanitizesNULInTitle(t *testing.T) {
 	assert.NotContains(t, outputPath, "\x00")
 	assert.NotContains(t, outputPath, "\x00")
 
 
 	output, err := os.ReadFile(outputPath)
 	output, err := os.ReadFile(outputPath)
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	assert.Equal(t, "nul ok", string(output))
 	assert.Equal(t, "nul ok", string(output))
 }
 }
 
 

+ 1 - 1
service/internal/executor/group_concurrency_test.go

@@ -359,7 +359,7 @@ func TestGroupQueuesThirdAndBlocksWhenQueueFull(t *testing.T) {
 	trackings := []string{tracking1, tracking2}
 	trackings := []string{tracking1, tracking2}
 	waitGroups := []*sync.WaitGroup{wg1, wg2}
 	waitGroups := []*sync.WaitGroup{wg1, wg2}
 
 
-	for idx := 0; idx < 3; idx++ {
+	for range 3 {
 		wg, tracking := e.ExecRequest(&ExecutionRequest{
 		wg, tracking := e.ExecRequest(&ExecutionRequest{
 			Binding:           binding,
 			Binding:           binding,
 			Cfg:               cfg,
 			Cfg:               cfg,

+ 6 - 6
service/internal/executor/loadlogs.go

@@ -18,12 +18,12 @@ func (e *Executor) LoadLogsFromDisk() {
 		return
 		return
 	}
 	}
 
 
-	entries, skippedCount := e.readLogDirectory(resultsDir)
+	entries := e.readLogDirectory(resultsDir)
 	if entries == nil {
 	if entries == nil {
 		return
 		return
 	}
 	}
 
 
-	loadedLogs, skippedCount := e.parseLogFiles(resultsDir, entries, skippedCount)
+	loadedLogs, skippedCount := e.parseLogFiles(resultsDir, entries, 0)
 
 
 	sort.Slice(loadedLogs, func(i, j int) bool {
 	sort.Slice(loadedLogs, func(i, j int) bool {
 		return loadedLogs[i].DatetimeStarted.Before(loadedLogs[j].DatetimeStarted)
 		return loadedLogs[i].DatetimeStarted.Before(loadedLogs[j].DatetimeStarted)
@@ -37,12 +37,12 @@ func (e *Executor) LoadLogsFromDisk() {
 	}).Info("Finished loading persisted logs from disk")
 	}).Info("Finished loading persisted logs from disk")
 }
 }
 
 
-func (e *Executor) readLogDirectory(resultsDir string) ([]os.DirEntry, int) {
+func (e *Executor) readLogDirectory(resultsDir string) []os.DirEntry {
 	if _, err := os.Stat(resultsDir); os.IsNotExist(err) {
 	if _, err := os.Stat(resultsDir); os.IsNotExist(err) {
 		log.WithFields(log.Fields{
 		log.WithFields(log.Fields{
 			"directory": resultsDir,
 			"directory": resultsDir,
 		}).Debug("Logs directory does not exist, skipping log loading")
 		}).Debug("Logs directory does not exist, skipping log loading")
-		return nil, 0
+		return nil
 	}
 	}
 
 
 	log.WithFields(log.Fields{
 	log.WithFields(log.Fields{
@@ -55,10 +55,10 @@ func (e *Executor) readLogDirectory(resultsDir string) ([]os.DirEntry, int) {
 			"directory": resultsDir,
 			"directory": resultsDir,
 			"error":     err,
 			"error":     err,
 		}).Warnf("Failed to read logs directory")
 		}).Warnf("Failed to read logs directory")
-		return nil, 0
+		return nil
 	}
 	}
 
 
-	return entries, 0
+	return entries
 }
 }
 
 
 func (e *Executor) parseLogFiles(resultsDir string, entries []os.DirEntry, skippedCount int) ([]*InternalLogEntry, int) {
 func (e *Executor) parseLogFiles(resultsDir string, entries []os.DirEntry, skippedCount int) ([]*InternalLogEntry, int) {

+ 5 - 10
service/internal/logfilter/filter.go

@@ -38,12 +38,7 @@ func Compile(expression string) (*vm.Program, error) {
 		return nil, fmt.Errorf("filter expression exceeds maximum length of %d characters", maxFilterLength)
 		return nil, fmt.Errorf("filter expression exceeds maximum length of %d characters", maxFilterLength)
 	}
 	}
 
 
-	normalized, err := normalizeExpression(trimmed)
-	if err != nil {
-		return nil, err
-	}
-
-	return compileNormalized(normalized)
+	return compileNormalized(normalizeExpression(trimmed))
 }
 }
 
 
 func compileNormalized(normalized string) (*vm.Program, error) {
 func compileNormalized(normalized string) (*vm.Program, error) {
@@ -114,19 +109,19 @@ func Matches(program *vm.Program, record Record) (bool, error) {
 	return matched, nil
 	return matched, nil
 }
 }
 
 
-func normalizeExpression(expression string) (string, error) {
+func normalizeExpression(expression string) string {
 	if isNegatedSearchTerm(expression) {
 	if isNegatedSearchTerm(expression) {
 		term := quoteLiteral(strings.TrimPrefix(expression, "!"))
 		term := quoteLiteral(strings.TrimPrefix(expression, "!"))
-		return negatedSearchExpression(term), nil
+		return negatedSearchExpression(term)
 	}
 	}
 
 
 	if isPositiveSearchTerm(expression) {
 	if isPositiveSearchTerm(expression) {
-		return positiveSearchExpression(quoteLiteral(expression)), nil
+		return positiveSearchExpression(quoteLiteral(expression))
 	}
 	}
 
 
 	normalized := replaceContainsOperators(expression)
 	normalized := replaceContainsOperators(expression)
 	normalized = replaceComparisons(normalized)
 	normalized = replaceComparisons(normalized)
-	return replaceBooleanWords(normalized), nil
+	return replaceBooleanWords(normalized)
 }
 }
 
 
 func isNegatedSearchTerm(expression string) bool {
 func isNegatedSearchTerm(expression string) bool {

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

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
 	"regexp"
 	"regexp"
+	"slices"
 	"strings"
 	"strings"
 	"text/template"
 	"text/template"
 
 
@@ -112,8 +113,7 @@ func migrateLegacyEntityProperties(rawShellCommand string) string {
 func migrateLegacyArgumentNames(rawShellCommand string) string {
 func migrateLegacyArgumentNames(rawShellCommand string) string {
 	matches := legacyArgumentRegex.FindAllStringSubmatchIndex(rawShellCommand, -1)
 	matches := legacyArgumentRegex.FindAllStringSubmatchIndex(rawShellCommand, -1)
 
 
-	for i := len(matches) - 1; i >= 0; i-- {
-		match := matches[i]
+	for _, match := range slices.Backward(matches) {
 		fullMatchStart := match[0]
 		fullMatchStart := match[0]
 		fullMatchEnd := match[1]
 		fullMatchEnd := match[1]
 		argNameStart := match[2]
 		argNameStart := match[2]

+ 3 - 2
service/internal/tpl/templates_test.go

@@ -7,6 +7,7 @@ import (
 
 
 	"github.com/OliveTin/OliveTin/internal/entities"
 	"github.com/OliveTin/OliveTin/internal/entities"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
 )
 )
 
 
 func TestParseTemplateWithActionContext_Json(t *testing.T) {
 func TestParseTemplateWithActionContext_Json(t *testing.T) {
@@ -60,7 +61,7 @@ func TestParseTemplateWithActionContext_Json(t *testing.T) {
 				assert.Error(t, err)
 				assert.Error(t, err)
 				return
 				return
 			}
 			}
-			assert.NoError(t, err)
+			require.NoError(t, err)
 			if tt.checkJsonOnly {
 			if tt.checkJsonOnly {
 				assertJsonOutput(t, output, tt.expectedOutput, tt.args)
 				assertJsonOutput(t, output, tt.expectedOutput, tt.args)
 			} else {
 			} else {
@@ -78,7 +79,7 @@ func assertJsonOutput(t *testing.T, output, expectedPrefix string, args map[stri
 	jsonPart = strings.TrimSpace(jsonPart)
 	jsonPart = strings.TrimSpace(jsonPart)
 	var decoded map[string]string
 	var decoded map[string]string
 	err := json.Unmarshal([]byte(jsonPart), &decoded)
 	err := json.Unmarshal([]byte(jsonPart), &decoded)
-	assert.NoError(t, err)
+	require.NoError(t, err)
 	for k, v := range args {
 	for k, v := range args {
 		assert.Equal(t, v, decoded[k], "decoded JSON should contain %s=%s", k, v)
 		assert.Equal(t, v, decoded[k], "decoded JSON should contain %s=%s", k, v)
 	}
 	}

+ 7 - 7
service/internal/webhooks/github.go

@@ -53,16 +53,16 @@ func setExtractIfMissing(cfg *config.WebhookConfig, key, value string) {
 	}
 	}
 }
 }
 
 
-func setMatchHeaderIfMissing(cfg *config.WebhookConfig, key, value string) {
-	if _, exists := cfg.MatchHeaders[key]; !exists {
-		cfg.MatchHeaders[key] = value
+func setGitHubEventHeaderIfMissing(cfg *config.WebhookConfig, event string) {
+	if _, exists := cfg.MatchHeaders["X-GitHub-Event"]; !exists {
+		cfg.MatchHeaders["X-GitHub-Event"] = event
 	}
 	}
 }
 }
 
 
 func applyGitHubPushTemplate(cfg *config.WebhookConfig) {
 func applyGitHubPushTemplate(cfg *config.WebhookConfig) {
 	setDefaultAuth(cfg)
 	setDefaultAuth(cfg)
 	ensureMatchHeaders(cfg)
 	ensureMatchHeaders(cfg)
-	setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "push")
+	setGitHubEventHeaderIfMissing(cfg, "push")
 	ensureExtract(cfg)
 	ensureExtract(cfg)
 	setExtractIfMissing(cfg, "git_repository", "$.repository.full_name")
 	setExtractIfMissing(cfg, "git_repository", "$.repository.full_name")
 	setExtractIfMissing(cfg, "git_ref", "$.ref")
 	setExtractIfMissing(cfg, "git_ref", "$.ref")
@@ -75,7 +75,7 @@ func applyGitHubPushTemplate(cfg *config.WebhookConfig) {
 func applyGitHubPRTemplate(cfg *config.WebhookConfig) {
 func applyGitHubPRTemplate(cfg *config.WebhookConfig) {
 	setDefaultAuth(cfg)
 	setDefaultAuth(cfg)
 	ensureMatchHeaders(cfg)
 	ensureMatchHeaders(cfg)
-	setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "pull_request")
+	setGitHubEventHeaderIfMissing(cfg, "pull_request")
 	ensureExtract(cfg)
 	ensureExtract(cfg)
 	setExtractIfMissing(cfg, "pr_number", "$.number")
 	setExtractIfMissing(cfg, "pr_number", "$.number")
 	setExtractIfMissing(cfg, "pr_title", "$.pull_request.title")
 	setExtractIfMissing(cfg, "pr_title", "$.pull_request.title")
@@ -89,7 +89,7 @@ func applyGitHubPRTemplate(cfg *config.WebhookConfig) {
 func applyGitHubReleaseTemplate(cfg *config.WebhookConfig) {
 func applyGitHubReleaseTemplate(cfg *config.WebhookConfig) {
 	setDefaultAuth(cfg)
 	setDefaultAuth(cfg)
 	ensureMatchHeaders(cfg)
 	ensureMatchHeaders(cfg)
-	setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "release")
+	setGitHubEventHeaderIfMissing(cfg, "release")
 	ensureExtract(cfg)
 	ensureExtract(cfg)
 	setExtractIfMissing(cfg, "release_action", "$.action")
 	setExtractIfMissing(cfg, "release_action", "$.action")
 	setExtractIfMissing(cfg, "release_tag", "$.release.tag_name")
 	setExtractIfMissing(cfg, "release_tag", "$.release.tag_name")
@@ -101,7 +101,7 @@ func applyGitHubReleaseTemplate(cfg *config.WebhookConfig) {
 func applyGitHubWorkflowTemplate(cfg *config.WebhookConfig) {
 func applyGitHubWorkflowTemplate(cfg *config.WebhookConfig) {
 	setDefaultAuth(cfg)
 	setDefaultAuth(cfg)
 	ensureMatchHeaders(cfg)
 	ensureMatchHeaders(cfg)
-	setMatchHeaderIfMissing(cfg, "X-GitHub-Event", "workflow_run")
+	setGitHubEventHeaderIfMissing(cfg, "workflow_run")
 	ensureExtract(cfg)
 	ensureExtract(cfg)
 	setExtractIfMissing(cfg, "workflow_name", "$.workflow_run.name")
 	setExtractIfMissing(cfg, "workflow_name", "$.workflow_run.name")
 	setExtractIfMissing(cfg, "workflow_status", "$.workflow_run.status")
 	setExtractIfMissing(cfg, "workflow_status", "$.workflow_run.status")

+ 2 - 0
service/syntaxcheck.go

@@ -7,6 +7,7 @@ import (
 
 
 	"github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/config"
 	"github.com/OliveTin/OliveTin/internal/configissues"
 	"github.com/OliveTin/OliveTin/internal/configissues"
+	"github.com/OliveTin/OliveTin/internal/entities"
 	"github.com/OliveTin/OliveTin/internal/executor"
 	"github.com/OliveTin/OliveTin/internal/executor"
 )
 )
 
 
@@ -20,6 +21,7 @@ func runSyntaxCheck() {
 		os.Exit(1)
 		os.Exit(1)
 	}
 	}
 
 
+	entities.SyncEntityFileWatchers(cfg)
 	executor.DefaultExecutor(cfg).RebuildActionMap()
 	executor.DefaultExecutor(cfg).RebuildActionMap()
 	printConfigIssues(configissues.List())
 	printConfigIssues(configissues.List())
 
 

+ 2 - 0
var/marketing/screenshots/.gitignore

@@ -0,0 +1,2 @@
+tmp/
+__pycache__/

+ 48 - 0
var/marketing/screenshots/Makefile

@@ -0,0 +1,48 @@
+# Regenerates marketing screenshots against a throwaway OliveTin that runs
+# the fictional config in seed/. Run `make` here (or `make screenshots` from
+# the repo root); OliveTin is always stopped afterwards.
+#
+# OliveTin keeps all of its state in config files and memory, so there are
+# no database or broker containers to start.
+
+ROOT := $(abspath ../../..)
+HERE := $(abspath .)
+TMP := $(HERE)/tmp
+
+BASE_URL := http://127.0.0.1:18337
+API := $(BASE_URL)/api/olivetin.api.v1.OliveTinApiService
+
+.PHONY: all build up seed shoot manifest down clean
+
+all:
+	@$(MAKE) build up seed shoot manifest; rc=$$?; $(MAKE) down; exit $$rc
+
+build:
+	$(MAKE) -C $(ROOT) service webui-dist
+
+up: down
+	@if curl -fsS --max-time 5 $(BASE_URL)/ >/dev/null 2>&1; then \
+		echo "Port 18337 is already in use"; exit 1; fi
+	rm -rf $(TMP)
+	mkdir -p $(TMP)
+	cp -r seed $(TMP)/config
+	# Started from service/ so that OliveTin finds ../webui.
+	cd $(ROOT)/service && { ./OliveTin -configdir $(TMP)/config > $(TMP)/service.log 2>&1 & echo $$! > $(TMP)/service.pid; }
+	@for i in $$(seq 60); do curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' -d '{}' \
+		$(API)/Init >/dev/null 2>&1 && exit 0; sleep 0.5; done; \
+		echo "OliveTin did not start; see $(TMP)/service.log"; exit 1
+
+seed:
+	./seed.sh $(BASE_URL)
+
+shoot:
+	repo-helper screenshot --config screenshots.ini
+
+manifest:
+	./generate-manifest.py
+
+down:
+	-@[ -f $(TMP)/service.pid ] && kill $$(cat $(TMP)/service.pid) 2>/dev/null; rm -f $(TMP)/service.pid
+
+clean: down
+	rm -rf $(TMP)

+ 41 - 0
var/marketing/screenshots/README.md

@@ -0,0 +1,41 @@
+# Marketing screenshots
+
+The screenshots used in the project README, captured from a throwaway
+OliveTin running the fictional config in [`seed/`](seed/).
+
+```bash
+make screenshots                      # from the repo root
+make -C var/marketing/screenshots     # or from anywhere
+```
+
+This builds OliveTin and the webui, starts OliveTin on `127.0.0.1:18337`
+with a copy of `seed/` in `tmp/`, runs a few actions through the API
+(`seed.sh`) so the logs have history, captures every section of
+`screenshots.ini` with `repo-helper screenshot`, regenerates
+`screenshot-manifest.yaml`, and then stops OliveTin, even if a step fails.
+
+## Requirements
+
+- Go and Node.js, to build OliveTin and the webui
+- [`repo-helper`](https://github.com/jamesread/repo-common), with Chrome and a
+  matching `chromedriver`
+- `curl` and `jq`
+
+No containers are needed: OliveTin keeps its state in config files and memory.
+
+## Files
+
+| File | Purpose |
+|------|---------|
+| `seed/config.yaml`, `seed/entities/` | Fictional config. Commands print static, made-up output, so images never show details of the machine running `make`. |
+| `seed.sh` | Runs actions with fixed execution tracking ids, so the logs and execution URLs are the same on every run. |
+| `screenshots.ini` | One section per image, plus the `title`/`description`/`theme` used for the manifest. |
+| `scripts/` | Selenium setup scripts. `light.py`/`dark.py` pin `prefers-color-scheme`, because repo-helper shares one browser session across sections. |
+| `generate-manifest.py` | Writes `screenshot-manifest.yaml`. |
+| `screenshot-manifest.yaml` | Generated index of the screenshots for indexers. Do not edit by hand. |
+
+To add or change a screenshot, edit `screenshots.ini` and run `make`. If only
+a title or description changed, `make manifest` is enough.
+
+To browse the demo instance yourself, run `make build up seed`, open
+<http://127.0.0.1:18337/>, and run `make down` when finished.

TEMPAT SAMPAH
var/marketing/screenshots/argument-form.png


TEMPAT SAMPAH
var/marketing/screenshots/dashboard-dark.png


TEMPAT SAMPAH
var/marketing/screenshots/dashboard-mobile.png


TEMPAT SAMPAH
var/marketing/screenshots/dashboard.png


TEMPAT SAMPAH
var/marketing/screenshots/entities.png


TEMPAT SAMPAH
var/marketing/screenshots/execution.png


+ 74 - 0
var/marketing/screenshots/generate-manifest.py

@@ -0,0 +1,74 @@
+#!/usr/bin/env python3
+"""Writes screenshot-manifest.yaml from screenshots.ini for indexers.
+
+Each INI section with url= is one screenshot; it must have title= and
+description=, and its PNG must exist. Run via `make manifest`.
+"""
+import configparser
+import json
+import struct
+import sys
+from pathlib import Path
+
+HERE = Path(__file__).resolve().parent
+INI = HERE / "screenshots.ini"
+OUT = HERE / "screenshot-manifest.yaml"
+
+
+def png_size(path):
+    with open(path, "rb") as f:
+        header = f.read(24)
+    if header[:8] != b"\x89PNG\r\n\x1a\n":
+        raise ValueError("%s is not a PNG" % path)
+    return struct.unpack(">II", header[16:24])
+
+
+def q(value):
+    # JSON strings are valid YAML scalars and handle all quoting/escaping.
+    return json.dumps(value, ensure_ascii=False)
+
+
+def main():
+    cp = configparser.ConfigParser(interpolation=None)
+    cp.read(INI)
+    defaults = cp.defaults()
+    base_url = defaults["manifest_base_url"].rstrip("/") + "/"
+    errors = []
+    lines = [
+        "# Generated by `make manifest` from screenshots.ini. Do not edit by hand.",
+        "version: 1",
+        "project: %s" % q(defaults["manifest_project"]),
+        "screenshots:",
+    ]
+    for section in cp.sections():
+        if not cp.get(section, "url", fallback="").strip():
+            continue
+        name = cp.get(section, "name", fallback=section).strip() or section
+        file = "%s.png" % name
+        title = cp.get(section, "title", fallback="").strip()
+        description = cp.get(section, "description", fallback="").strip()
+        if not title or not description:
+            errors.append("[%s] needs title= and description=" % section)
+            continue
+        if not (HERE / file).is_file():
+            errors.append("[%s] %s is missing; run `make` first" % (section, file))
+            continue
+        width, height = png_size(HERE / file)
+        lines += [
+            "  - name: %s" % q(name),
+            "    title: %s" % q(title),
+            "    description: %s" % q(description),
+            "    url: %s" % q(base_url + file),
+            "    file: %s" % q(file),
+            "    width: %d" % width,
+            "    height: %d" % height,
+            "    theme: %s" % q(cp.get(section, "theme", fallback="light").strip()),
+        ]
+    if errors:
+        sys.exit("\n".join(errors))
+    OUT.write_text("\n".join(lines) + "\n")
+    print("Wrote %s" % OUT.name)
+
+
+if __name__ == "__main__":
+    main()

TEMPAT SAMPAH
var/marketing/screenshots/logs.png


+ 60 - 0
var/marketing/screenshots/screenshot-manifest.yaml

@@ -0,0 +1,60 @@
+# Generated by `make manifest` from screenshots.ini. Do not edit by hand.
+version: 1
+project: "OliveTin"
+screenshots:
+  - name: "dashboard"
+    title: "Action buttons"
+    description: "Your predefined shell commands appear as simple, touch-friendly buttons, so anyone can run them safely without a terminal."
+    url: "https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/dashboard.png"
+    file: "dashboard.png"
+    width: 1280
+    height: 800
+    theme: "light"
+  - name: "argument-form"
+    title: "Arguments"
+    description: "Actions can ask for input before they run, with typed and validated fields, defaults and dropdowns, so commands only ever run with sensible values."
+    url: "https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/argument-form.png"
+    file: "argument-form.png"
+    width: 1280
+    height: 800
+    theme: "light"
+  - name: "execution"
+    title: "Command output"
+    description: "Watch a command's output live as it runs, along with whether it succeeded and how long it took."
+    url: "https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/execution.png"
+    file: "execution.png"
+    width: 1280
+    height: 800
+    theme: "light"
+  - name: "entities"
+    title: "Dashboards and entities"
+    description: "Build dashboards that group actions by the things you manage, such as servers or containers, with one set of buttons generated for each of them."
+    url: "https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/entities.png"
+    file: "entities.png"
+    width: 1280
+    height: 800
+    theme: "light"
+  - name: "logs"
+    title: "Execution logs"
+    description: "Every action that runs is logged, so you can see who ran what, when, and whether it succeeded."
+    url: "https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/logs.png"
+    file: "logs.png"
+    width: 1280
+    height: 800
+    theme: "light"
+  - name: "dashboard-dark"
+    title: "Dark mode"
+    description: "OliveTin follows your browser's dark mode preference automatically."
+    url: "https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/dashboard-dark.png"
+    file: "dashboard-dark.png"
+    width: 1280
+    height: 800
+    theme: "dark"
+  - name: "dashboard-mobile"
+    title: "Mobile layout"
+    description: "The responsive layout works well on phones and on wall-mounted tablets, so commands are a tap away wherever you are."
+    url: "https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/dashboard-mobile.png"
+    file: "dashboard-mobile.png"
+    width: 412
+    height: 915
+    theme: "light"

+ 58 - 0
var/marketing/screenshots/screenshots.ini

@@ -0,0 +1,58 @@
+# Marketing screenshots, captured by `make` in this directory against the
+# throwaway OliveTin it starts with the fictional config in seed/.
+#
+# title/description/theme are not used by repo-helper; `make manifest` turns
+# them into screenshot-manifest.yaml for indexers. Every section needs them.
+
+[DEFAULT]
+base_url = http://127.0.0.1:18337/
+dir = .
+width = 1280
+height = 800
+script = scripts/light.py, scripts/prepare.py
+post_script_sleep = 1.5
+theme = light
+manifest_project = OliveTin
+manifest_base_url = https://raw.githubusercontent.com/OliveTin/OliveTin/main/var/marketing/screenshots/
+
+[dashboard]
+url = .
+title = Action buttons
+description = Your predefined shell commands appear as simple, touch-friendly buttons, so anyone can run them safely without a terminal.
+
+[argument-form]
+url = .
+script = scripts/light.py, scripts/prepare.py, scripts/argument_form.py
+title = Arguments
+description = Actions can ask for input before they run, with typed and validated fields, defaults and dropdowns, so commands only ever run with sensible values.
+
+[execution]
+url = logs/5eed0000-0000-4000-8000-000000000007
+title = Command output
+description = Watch a command's output live as it runs, along with whether it succeeded and how long it took.
+
+[entities]
+url = dashboards/Servers
+title = Dashboards and entities
+description = Build dashboards that group actions by the things you manage, such as servers or containers, with one set of buttons generated for each of them.
+
+[logs]
+url = logs
+title = Execution logs
+description = Every action that runs is logged, so you can see who ran what, when, and whether it succeeded.
+
+# Dark and mobile variants last; the browser session is shared, so each
+# section pins its own color scheme.
+[dashboard-dark]
+url = .
+script = scripts/dark.py, scripts/prepare.py
+theme = dark
+title = Dark mode
+description = OliveTin follows your browser's dark mode preference automatically.
+
+[dashboard-mobile]
+url = .
+width = 412
+height = 915
+title = Mobile layout
+description = The responsive layout works well on phones and on wall-mounted tablets, so commands are a tap away wherever you are.

+ 10 - 0
var/marketing/screenshots/scripts/argument_form.py

@@ -0,0 +1,10 @@
+"""Opens the argument form for the "Ping host" action."""
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def run(driver):
+    driver.find_element(By.CSS_SELECTOR, '[title="Ping host"]').click()
+    WebDriverWait(driver, 15).until(
+        lambda d: d.find_element(By.TAG_NAME, "body").get_attribute("loaded-argument-form")
+    )

+ 10 - 0
var/marketing/screenshots/scripts/dark.py

@@ -0,0 +1,10 @@
+"""Forces the dark color scheme before capture."""
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(__file__))
+from theme import set_color_scheme  # noqa: E402
+
+
+def run(driver):
+    set_color_scheme(driver, "dark")

+ 10 - 0
var/marketing/screenshots/scripts/light.py

@@ -0,0 +1,10 @@
+"""Forces the light color scheme before capture."""
+import os
+import sys
+
+sys.path.insert(0, os.path.dirname(__file__))
+from theme import set_color_scheme  # noqa: E402
+
+
+def run(driver):
+    set_color_scheme(driver, "light")

+ 19 - 0
var/marketing/screenshots/scripts/prepare.py

@@ -0,0 +1,19 @@
+"""Waits for the SPA to render before capture."""
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def _rendered(driver):
+    if driver.find_element(By.TAG_NAME, "body").get_attribute("loaded-dashboard"):
+        return True
+    if driver.find_elements(By.CSS_SELECTOR, "main table tbody tr"):
+        return True
+    return any(rows.text.strip() for rows in driver.find_elements(By.CSS_SELECTOR, ".xterm-rows"))
+
+
+def run(driver):
+    WebDriverWait(driver, 30).until(_rendered)
+    time.sleep(1)
+    driver.execute_script("window.scrollTo(0, 0);")

+ 12 - 0
var/marketing/screenshots/scripts/theme.py

@@ -0,0 +1,12 @@
+"""Pins prefers-color-scheme; OliveTin's dark mode follows the browser.
+
+repo-helper reuses one browser session for every section, so each section
+must set the scheme explicitly or it inherits the previous one.
+"""
+
+
+def set_color_scheme(driver, scheme):
+    driver.execute_cdp_cmd(
+        "Emulation.setEmulatedMedia",
+        {"features": [{"name": "prefers-color-scheme", "value": scheme}]},
+    )

+ 36 - 0
var/marketing/screenshots/seed.sh

@@ -0,0 +1,36 @@
+#!/usr/bin/env bash
+# Runs a few fictional actions through OliveTin's public API so that the
+# logs and execution pages have history to show. Fixed tracking ids keep
+# the logs table and execution URLs the same on every run.
+# Usage: seed.sh <base-url>
+set -euo pipefail
+
+base="${1:?base url}"
+api="$base/api/olivetin.api.v1.OliveTinApiService"
+
+rpc() {
+	curl -fsS --max-time 10 -X POST -H 'Content-Type: application/json' -d "$2" "$api/$1"
+}
+
+run() {
+	local tracking_id="$1" binding_id="$2" arguments="${3:-[]}"
+
+	rpc StartAction "{\"bindingId\": \"$binding_id\", \"uniqueTrackingId\": \"$tracking_id\", \"arguments\": $arguments}" >/dev/null
+
+	for _ in $(seq 50); do
+		rpc ExecutionStatus "{\"executionTrackingId\": \"$tracking_id\"}" |
+			jq -e '.logEntry.executionFinished' >/dev/null && return 0
+		sleep 0.1
+	done
+
+	echo "Action $binding_id did not finish" >&2
+	return 1
+}
+
+run 5eed0000-0000-4000-8000-000000000001 run_playbook
+run 5eed0000-0000-4000-8000-000000000002 nightly_backup
+run 5eed0000-0000-4000-8000-000000000003 restart_container '[{"name": "container", "value": "demo-web"}]'
+run 5eed0000-0000-4000-8000-000000000004 restart_media
+run 5eed0000-0000-4000-8000-000000000005 porch_light
+run 5eed0000-0000-4000-8000-000000000006 ping_host '[{"name": "host", "value": "nas.example.com"}, {"name": "count", "value": "4"}]'
+run 5eed0000-0000-4000-8000-000000000007 check_disk

+ 204 - 0
var/marketing/screenshots/seed/config.yaml

@@ -0,0 +1,204 @@
+# Fictional OliveTin config used only for marketing screenshots.
+#
+# Every command prints static, made-up output so that captures are
+# repeatable and never show details of the machine running `make`.
+# Hostnames use example.com and the 192.0.2.0/24 documentation range.
+
+listenAddressSingleHTTPFrontend: 127.0.0.1:18337
+logLevel: "WARN"
+checkForUpdates: false
+pageTitle: OliveTin
+showFooter: false
+
+actions:
+  - title: Restart media server
+    id: restart_media
+    icon: restart
+    shell: |
+      echo "Stopping demo-media..."
+      sleep 1
+      echo "Starting demo-media..."
+      echo "demo-media is running."
+
+  - title: Check disk space
+    id: check_disk
+    icon: disk
+    onclick: execution-dialog
+    shell: |
+      printf '%s\n' \
+        'Filesystem      Size  Used Avail Use% Mounted on' \
+        '/dev/sda2       457G  182G  252G  42% /' \
+        '/dev/sda1       974M  211M  696M  24% /boot' \
+        '/dev/sdb1       3.6T  2.1T  1.4T  61% /srv/media' \
+        '/dev/sdc1       1.8T  640G  1.1T  37% /srv/backups'
+
+  - title: Ping host
+    id: ping_host
+    icon: ping
+    shell: |
+      echo "PING {{ host }} (192.0.2.10) 56(84) bytes of data."
+      for i in $(seq {{ count }}); do
+        echo "64 bytes from {{ host }} (192.0.2.10): icmp_seq=$i ttl=64 time=0.42 ms"
+      done
+      echo ""
+      echo "--- {{ host }} ping statistics ---"
+      echo "{{ count }} packets transmitted, {{ count }} received, 0% packet loss"
+    arguments:
+      - name: host
+        title: Host
+        type: ascii_identifier
+        default: nas.example.com
+        description: The host that you want to ping.
+      - name: count
+        title: Count
+        type: int
+        default: 3
+        description: How many pings to send.
+
+  - title: Restart container
+    id: restart_container
+    icon: box
+    shell: 'echo "Restarting container {{ container }}..." && echo "{{ container }} restarted."'
+    arguments:
+      - name: container
+        title: Container
+        description: Pick the container to restart.
+        choices:
+          - title: Web server (demo-web)
+            value: demo-web
+          - title: Database (demo-db)
+            value: demo-db
+          - title: Cache (demo-cache)
+            value: demo-cache
+
+  - title: Run nightly backup
+    id: nightly_backup
+    icon: backup
+    groups: [ backup-jobs ]
+    shell: |
+      echo "Backing up /srv/media to backup.example.com..."
+      echo "Transferred 1,284 files (3.2 GB)."
+      echo "Backup complete."
+
+  - title: Backup selected folders
+    id: backup_folders
+    icon: backup
+    groups: [ backup-jobs ]
+    shell: 'echo "Backing up: {{ folders }}"'
+    arguments:
+      - name: folders
+        title: Folders to back up
+        type: checklist
+        description: Select one or more folders to include in the backup.
+        choices:
+          - title: Documents
+            value: documents
+          - title: Photos
+            value: photos
+          - title: Music
+            value: music
+          - title: Videos
+            value: videos
+        default: '["documents","photos"]'
+
+  - title: Toggle porch light
+    id: porch_light
+    icon: light
+    shell: echo "Porch light toggled."
+
+  - title: Run automation playbook
+    id: run_playbook
+    icon: robot
+    shell: |
+      echo "PLAY [homelab] *****************************************************"
+      echo "TASK [Update packages] *********************************************"
+      echo "ok: [web.example.com]"
+      echo "ok: [nas.example.com]"
+      echo "PLAY RECAP *********************************************************"
+      echo "web.example.com : ok=4 changed=1 unreachable=0 failed=0"
+      echo "nas.example.com : ok=4 changed=0 unreachable=0 failed=0"
+
+  - title: Enable SSH for 20 minutes
+    id: enable_ssh
+    icon: ssh
+    shell: echo "SSH enabled until the timer expires."
+
+  - title: Search the logs
+    id: search_logs
+    icon: logs
+    shell: 'echo "No matches for {{ pattern }}."'
+    arguments:
+      - name: pattern
+        title: Pattern
+        type: ascii_sentence
+        default: error
+
+  - title: Delete old backups
+    id: delete_old_backups
+    icon: ashtonished
+    shell: echo "Deleted 4 backups older than 90 days."
+    arguments:
+      - type: html
+        title: Description
+        default: This removes backups older than 90 days from <strong>backup.example.com</strong>.
+      - type: confirmation
+        title: Are you sure?
+
+  - title: Change theme
+    id: change_theme
+    icon: theme
+    shell: echo "Theme changed."
+
+  - title: "Wake {{ server.name }}"
+    icon: reboot
+    entity: server
+    shell: 'echo "Sending Wake on LAN to {{ server.hostname }}"'
+
+  - title: "Update {{ server.name }}"
+    icon: robot
+    entity: server
+    shell: 'echo "Updating {{ server.hostname }}"'
+
+  - title: "Back up {{ server.name }}"
+    icon: backup
+    entity: server
+    shell: 'echo "Backing up {{ server.hostname }}"'
+
+entities:
+  - file: entities/servers.yaml
+    name: server
+    icon: ssh
+    properties:
+      - name: hostname
+        title: Hostname
+      - name: ip
+        title: IP address
+
+actionGroups:
+  backup-jobs:
+    maxConcurrent: 1
+    icon: backup
+
+dashboards:
+  - title: Servers
+    contents:
+      - type: fieldset
+        entity: server
+        title: '{{ server.name }}'
+        contents:
+          - type: display
+            title: |
+              Hostname: <strong>{{ server.hostname }}</strong><br />
+              IP address: <strong>{{ server.ip }}</strong>
+          - title: 'Wake {{ server.name }}'
+          - title: 'Update {{ server.name }}'
+          - title: 'Back up {{ server.name }}'
+
+defaultPolicy:
+  showDiagnostics: true
+  showLogList: true
+
+defaultPermissions:
+  view: true
+  exec: true
+  logs: true

+ 10 - 0
var/marketing/screenshots/seed/entities/servers.yaml

@@ -0,0 +1,10 @@
+# Fictional servers for marketing screenshots.
+- name: web
+  hostname: web.example.com
+  ip: 192.0.2.10
+- name: nas
+  hostname: nas.example.com
+  ip: 192.0.2.20
+- name: media
+  hostname: media.example.com
+  ip: 192.0.2.30

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini