James Read hace 2 semanas
padre
commit
c0fb2e010e
Se han modificado 100 ficheros con 1676 adiciones y 63 borrados
  1. 3 0
      .github/workflows/build-and-release.yml
  2. 6 0
      .github/workflows/codeql-analysis.yml
  3. 5 0
      .github/workflows/codestyle.yml
  4. 11 1
      .github/workflows/docs-antora.yml
  5. 4 1
      .gitignore
  6. 8 0
      AGENTS.md
  7. 2 0
      Dockerfile.multiarches
  8. 2 0
      Dockerfile.singlearch
  9. 12 7
      SECURITY.md
  10. 68 54
      config.yaml
  11. 31 0
      docs/modules/ROOT/examples/solutions/human-in-the-control-loop/config.yaml
  12. 2 0
      docs/modules/ROOT/images/.gitignore
  13. 163 0
      docs/modules/ROOT/images/SCREENSHOTS.md
  14. 2 0
      docs/modules/ROOT/images/action_buttons/create_your_first/.gitignore
  15. 2 0
      docs/modules/ROOT/images/action_buttons/create_your_first/Makefile
  16. 12 0
      docs/modules/ROOT/images/action_buttons/create_your_first/config.yaml
  17. BIN
      docs/modules/ROOT/images/action_buttons/create_your_first/hello-world.png
  18. 11 0
      docs/modules/ROOT/images/action_buttons/create_your_first/screenshots.ini
  19. 20 0
      docs/modules/ROOT/images/action_buttons/create_your_first/setup_hello.py
  20. 2 0
      docs/modules/ROOT/images/action_buttons/layout/.gitignore
  21. 2 0
      docs/modules/ROOT/images/action_buttons/layout/Makefile
  22. 37 0
      docs/modules/ROOT/images/action_buttons/layout/config.yaml
  23. BIN
      docs/modules/ROOT/images/action_buttons/layout/layout.png
  24. 11 0
      docs/modules/ROOT/images/action_buttons/layout/screenshots.ini
  25. 83 0
      docs/modules/ROOT/images/action_buttons/layout/setup_layout.py
  26. 2 0
      docs/modules/ROOT/images/action_customization/execution-dialog/.gitignore
  27. 2 0
      docs/modules/ROOT/images/action_customization/execution-dialog/Makefile
  28. 17 0
      docs/modules/ROOT/images/action_customization/execution-dialog/config.yaml
  29. BIN
      docs/modules/ROOT/images/action_customization/execution-dialog/executionDialog.png
  30. 11 0
      docs/modules/ROOT/images/action_customization/execution-dialog/screenshots.ini
  31. 45 0
      docs/modules/ROOT/images/action_customization/execution-dialog/setup_execution_dialog.py
  32. 2 0
      docs/modules/ROOT/images/action_customization/timeout-logs/.gitignore
  33. 2 0
      docs/modules/ROOT/images/action_customization/timeout-logs/Makefile
  34. 11 0
      docs/modules/ROOT/images/action_customization/timeout-logs/config.yaml
  35. 11 0
      docs/modules/ROOT/images/action_customization/timeout-logs/screenshots.ini
  36. 42 0
      docs/modules/ROOT/images/action_customization/timeout-logs/setup_timeout_logs.py
  37. BIN
      docs/modules/ROOT/images/action_customization/timeout-logs/timeoutLogs.png
  38. BIN
      docs/modules/ROOT/images/arg-suggestions-chrome.png
  39. BIN
      docs/modules/ROOT/images/arg-suggestions-firefox.png
  40. 2 0
      docs/modules/ROOT/images/args/input/.gitignore
  41. 2 0
      docs/modules/ROOT/images/args/input/Makefile
  42. BIN
      docs/modules/ROOT/images/args/input/args1.png
  43. BIN
      docs/modules/ROOT/images/args/input/args2.png
  44. BIN
      docs/modules/ROOT/images/args/input/args3.png
  45. 16 0
      docs/modules/ROOT/images/args/input/config.yaml
  46. 21 0
      docs/modules/ROOT/images/args/input/screenshots.ini
  47. 19 0
      docs/modules/ROOT/images/args/input/setup_args1.py
  48. 36 0
      docs/modules/ROOT/images/args/input/setup_args2.py
  49. 69 0
      docs/modules/ROOT/images/args/input/setup_args3.py
  50. 2 0
      docs/modules/ROOT/images/args/suggestions/.gitignore
  51. 2 0
      docs/modules/ROOT/images/args/suggestions/Makefile
  52. BIN
      docs/modules/ROOT/images/args/suggestions/arg-suggestions-chrome.png
  53. BIN
      docs/modules/ROOT/images/args/suggestions/arg-suggestions-firefox.png
  54. 21 0
      docs/modules/ROOT/images/args/suggestions/config.yaml
  55. 16 0
      docs/modules/ROOT/images/args/suggestions/screenshots.ini
  56. 93 0
      docs/modules/ROOT/images/args/suggestions/setup_chrome.py
  57. 77 0
      docs/modules/ROOT/images/args/suggestions/setup_firefox.py
  58. BIN
      docs/modules/ROOT/images/args1.png
  59. BIN
      docs/modules/ROOT/images/args2.png
  60. BIN
      docs/modules/ROOT/images/args3.png
  61. BIN
      docs/modules/ROOT/images/dashboard.png
  62. 2 0
      docs/modules/ROOT/images/dashboards/intro/.gitignore
  63. 2 0
      docs/modules/ROOT/images/dashboards/intro/Makefile
  64. 53 0
      docs/modules/ROOT/images/dashboards/intro/config.yaml
  65. BIN
      docs/modules/ROOT/images/dashboards/intro/preview.png
  66. 11 0
      docs/modules/ROOT/images/dashboards/intro/screenshots.ini
  67. 9 0
      docs/modules/ROOT/images/dashboards/intro/servers.yaml
  68. 42 0
      docs/modules/ROOT/images/dashboards/intro/setup_preview.py
  69. BIN
      docs/modules/ROOT/images/executionDialog.png
  70. BIN
      docs/modules/ROOT/images/hello-world.png
  71. 2 0
      docs/modules/ROOT/images/logs/views/.gitignore
  72. 2 0
      docs/modules/ROOT/images/logs/views/Makefile
  73. 31 0
      docs/modules/ROOT/images/logs/views/config.yaml
  74. BIN
      docs/modules/ROOT/images/logs/views/logsCalendar.png
  75. BIN
      docs/modules/ROOT/images/logs/views/logsList.png
  76. BIN
      docs/modules/ROOT/images/logs/views/logsQueue.png
  77. 24 0
      docs/modules/ROOT/images/logs/views/screenshots.ini
  78. 78 0
      docs/modules/ROOT/images/logs/views/setup_logs_calendar.py
  79. 73 0
      docs/modules/ROOT/images/logs/views/setup_logs_list.py
  80. 69 0
      docs/modules/ROOT/images/logs/views/setup_logs_queue.py
  81. 49 0
      docs/modules/ROOT/images/screenshots.mk
  82. BIN
      docs/modules/ROOT/images/solution-k8s-hosted.png
  83. BIN
      docs/modules/ROOT/images/solution-systemd-control-panel.png
  84. 2 0
      docs/modules/ROOT/images/solutions/container-control-panel/.gitignore
  85. 2 0
      docs/modules/ROOT/images/solutions/container-control-panel/Makefile
  86. 34 0
      docs/modules/ROOT/images/solutions/container-control-panel/config.yaml
  87. 2 0
      docs/modules/ROOT/images/solutions/container-control-panel/containers.json
  88. BIN
      docs/modules/ROOT/images/solutions/container-control-panel/preview.png
  89. 11 0
      docs/modules/ROOT/images/solutions/container-control-panel/screenshots.ini
  90. 42 0
      docs/modules/ROOT/images/solutions/container-control-panel/setup_preview.py
  91. 2 0
      docs/modules/ROOT/images/solutions/human-in-the-control-loop/.gitignore
  92. 2 0
      docs/modules/ROOT/images/solutions/human-in-the-control-loop/Makefile
  93. 33 0
      docs/modules/ROOT/images/solutions/human-in-the-control-loop/config.yaml
  94. BIN
      docs/modules/ROOT/images/solutions/human-in-the-control-loop/preview.png
  95. 11 0
      docs/modules/ROOT/images/solutions/human-in-the-control-loop/screenshots.ini
  96. 34 0
      docs/modules/ROOT/images/solutions/human-in-the-control-loop/setup_preview.py
  97. 2 0
      docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/.gitignore
  98. 2 0
      docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/Makefile
  99. 30 0
      docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/config.yaml
  100. BIN
      docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/preview.png

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

@@ -52,12 +52,15 @@ jobs:
         if: github.event_name != 'pull_request'
         uses: actions/setup-node@v6.4.0
         with:
+          node-version: '22'
           cache: 'npm'
           cache-dependency-path: frontend/package-lock.json
 
       - name: Setup node
         if: github.event_name == 'pull_request'
         uses: actions/setup-node@v6.4.0
+        with:
+          node-version: '22'
 
       - name: Setup Go
         uses: actions/setup-go@v6

+ 6 - 0
.github/workflows/codeql-analysis.yml

@@ -57,6 +57,12 @@ jobs:
           cache: true
           cache-dependency-path: 'service/go.mod'
 
+      - name: Setup Node
+        if: matrix.language == 'javascript'
+        uses: actions/setup-node@v4
+        with:
+          node-version: '22'
+
       # Initializes the CodeQL tools for scanning.
       - name: Initialize CodeQL
         uses: github/codeql-action/init@v3

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

@@ -31,5 +31,10 @@ jobs:
       - name: service
         run: make -wC service codestyle
 
+      - name: Setup Node
+        uses: actions/setup-node@v4
+        with:
+          node-version: '22'
+
       - name: frontend
         run: make -wC frontend codestyle

+ 11 - 1
.github/workflows/docs-antora.yml

@@ -25,10 +25,20 @@ jobs:
       - name: Install Node.js
         uses: actions/setup-node@v4
         with:
-          node-version: '20'
+          node-version: '22'
 
       - name: Install Antora toolchain
         run: npm i antora@3.1.14 asciidoctor-kroki@0.18.1 @asciidoctor/tabs@1.0.0-beta.6
 
       - name: Generate docs site (smoke)
         run: npx antora local-antora-playbook-ci.yml --log-level info
+
+  trigger-docs-publish:
+    needs: antora
+    if: github.event_name == 'push' && github.ref == 'refs/heads/next'
+    runs-on: ubuntu-latest
+    steps:
+      - name: Trigger docs.olivetin.app publish
+        env:
+          GH_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
+        run: gh workflow run asciidoc.yml --repo OliveTin/docs.olivetin.app --ref main

+ 4 - 1
.gitignore

@@ -12,6 +12,8 @@ frontend/dist/
 frontend/node_modules
 custom-frontend
 integration-tests/screenshots/
+integration-tests/flakey-test-runs.log
+integration-tests/flakey-test-runs.jsonl
 .vscode/
 webui/
 server.log
@@ -21,4 +23,5 @@ webui
 webui.dev
 sessions.yaml
 docs/build/
-build/
+build/
+.cursor

+ 8 - 0
AGENTS.md

@@ -66,6 +66,14 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
 - Review the pull request template at `.github/PULL_REQUEST_TEMPLATE.md`.
 - When changing behaviour covered by a spec in `specs/`, ensure implementation and tests match the spec.
 
+### Branch Naming
+Use conventional-commit-style branch names with a type prefix, optional issue reference, and a short kebab-case description:
+
+- `feat/[#123]-add-justification-prompt`
+- `fix/[#456]-websocket-reconnect`
+
+Do **not** use `feat-...`, `feature/...`, or other variants. Omit the `[#<issue>]` segment only when there is no linked issue.
+
 ### Troubleshooting
 - API tests failing with content-type errors: ensure Connect handler is served under `/api/` and the client targets that base URL.
 - Executor panics: check for nil `Binding/Action` and add guards in step functions.

+ 2 - 0
Dockerfile.multiarches

@@ -42,6 +42,8 @@ EXPOSE 1337/tcp
 
 COPY config.yaml /config
 COPY var/entities/* /config/entities/
+COPY examples/backupScript.sh /opt/backupScript.sh
+RUN chmod 755 /opt/backupScript.sh
 VOLUME /config
 
 ARG TARGETPLATFORM

+ 2 - 0
Dockerfile.singlearch

@@ -35,6 +35,8 @@ EXPOSE 1337/tcp
 
 COPY config.yaml /config
 COPY var/entities/* /config/entities/
+COPY examples/backupScript.sh /opt/backupScript.sh
+RUN chmod 755 /opt/backupScript.sh
 VOLUME /config
 
 COPY OliveTin /usr/bin/OliveTin

+ 12 - 7
SECURITY.md

@@ -13,15 +13,15 @@ To understand more about 2k vs 3k, see the following docs; https://docs.olivetin
 
 ## OliveTin *is* a remote code execution (RCE) "tool"
 
-The very purpose of OliveTin is to allow users to execute commands remotely on a machine. 
+The very purpose of OliveTin is to allow users to execute commands remotely on a machine.
 
-This means that, by design, OliveTin has much higher potential to be used for remote code execution (RCE), and any security vulnerabilities that do occur have the potential to be much more severe than in other types of software. 
+This means that, by design, OliveTin has much higher potential to be used for remote code execution (RCE), and any security vulnerabilities that do occur have the potential to be much more severe than in other types of software.
 
 We hope that you understand that while the project goes to great aims to be safe, and mitigate, that security vulnerabilities are inevitable, as they are with all software of all sizes - like Kubernetes, the Kernel, etc - and OliveTin has substantially less resources than those projects.
 
 With that being said, OliveTin tries to follow examples of best practice, so judge the project not on if/when it has security issues, but how security issues are responded to as the measure of quality.
 
-This is why we take security very seriously, and why we encourage responsible disclosure practices when reporting vulnerabilities. 
+This is why we take security very seriously, and why we encourage responsible disclosure practices when reporting vulnerabilities.
 
 ## Reporting a Vulnerability
 
@@ -29,7 +29,7 @@ Please use responsible disclosure practices when reporting a vulnerability. **Yo
 
 * **Option A (preferred)**: GitHub Security Advisories, which allows you to report a vulnerability privately and securely. Use this direct link to report privately: `https://github.com/OliveTin/OliveTin/security/advisories/new`. This allows you to provide details without making them public.
 
-* **Option B**: Please email `contact@jread.com` for responsible disclosure. 
+* **Option B**: Please email `contact@jread.com` for responsible disclosure.
 
 The following notes might be helpful when reporting a vulnerability:
 
@@ -41,15 +41,20 @@ The following notes might be helpful when reporting a vulnerability:
 
 It is incredibly useful to not just patch security vulnerabilities, but also to understand how they were found. If you are able to share this information, it can help us and the community to better understand potential attack vectors and improve the overall security of the project.
 
+## Duplicate reports
+
+If you are reporting via GitHub Security Advisories, search existing [repository advisories](https://github.com/OliveTin/OliveTin/security/advisories) for the same component and attack path before filing. Maintainers may close duplicate submissions and continue work on a single canonical advisory; duplicate reporters are still credited.
+
+Maintainers: see [.github/SECURITY_ADVISORY_DUPLICATES.md](.github/SECURITY_ADVISORY_DUPLICATES.md) for known duplicate pairs, triage steps, and OAuth2 issues that are easy to confuse with each other.
+
 ## Process
 
 Once a vulnerability is reported, the process is;
 
+* Check [.github/SECURITY_ADVISORY_DUPLICATES.md](.github/SECURITY_ADVISORY_DUPLICATES.md) and open advisories for duplicates before accepting.
 * Accept or reject the report, and communicate with the reporter about next steps.
 * If accepted, patch using a temporary branch, and code review will be requested from the original reporter if they are interested.
 * The severity of the vulnerability will be assessed using CVSS, and the patch will be prioritised accordingly.
 * Once the patch is ready, it will be queued for a release onto the `next` branch (3k) or `release/2k` branch (2k)
 * The reporter will be credited in the advisory and the release notes, but not the commit message.
-* The commit message will contain a reference to the CVSS score (eg: MED) and the advisory ID. 
-
-
+* The commit message will contain a reference to the CVSS score (eg: MED) and the advisory ID.

+ 68 - 54
config.yaml

@@ -6,14 +6,35 @@
 listenAddressSingleHTTPFrontend: 0.0.0.0:1337
 
 # Choose from INFO (default), WARN and DEBUG
-# Docs: https://docs.olivetin.app/advanced_configuration/logs.html 
+# Docs: https://docs.olivetin.app/advanced_configuration/logs.html
 logLevel: "INFO"
 
 # Actions are commands that are executed by OliveTin, and normally show up as
 # buttons on the WebUI.
 #
-# Docs: https://docs.olivetin.app/action_execution/create_your_first.html
+# Docs: https://docs.olivetin.app/action_buttons/create_your_first.html
 actions:
+  # Lots of people use OliveTin to build web interfaces for their electronics
+  # projects. It's best to install OliveTin as a native package (eg, .deb), and
+  # then you can use either a python script or the `gpio` command.
+  - title: Toggle GPIO light
+    shell: gpioset gpiochip1 9=1 || true # The "|| true" is to ignore errors the demo environment doesn't have GPIO access.
+    icon: light
+
+  # Lots of people also use OliveTin to monitor their servers, like checking
+  # disk space, or checking logs. `onclick: execution-dialog` shows output.
+  - title: Check disk space
+    icon: disk
+    shell: df -h /
+    onclick: execution-dialog
+
+  # This uses `onclick: execution-dialog` to show a dialog with more
+  # information about the command that was run.
+  - title: Check shell history
+    shell: cat ~/.bash_history
+    icon: logs
+    onclick: execution-dialog
+
   # Every action can still be run on demand from the web UI or API. The keys
   # below are optional *additional* triggers (see each action and
   # https://docs.olivetin.app/action_execution/ ).
@@ -26,62 +47,41 @@ actions:
   - title: Ping the Internet
     shell: ping -c 3 1.1.1.1
     icon: ping
-    popupOnStart: execution-dialog-stdout-only
+    onclick: execution-dialog
     # https://docs.olivetin.app/action_execution/onstartup.html
     execOnStartup: true
 
-  # This uses `popupOnStart: execution-dialog-stdout-only` to simply show just
-  # the command output.
-  - title: Check disk space
-    icon: disk
-    shell: df -h /media
-    popupOnStart: execution-dialog-stdout-only
-    # https://docs.olivetin.app/action_execution/onfilechanged.html
-    # Create the directory first, e.g. mkdir -p /tmp/olivetin-demo-file-changed
-    execOnFileChangedInDir:
-      - /tmp/olivetin-demo-file-changed
-
-  # This uses `popupOnStart: execution-dialog` to show a dialog with more
-  # information about the command that was run.
-  - title: check dmesg logs
-    shell: dmesg | tail
-    icon: logs
-    popupOnStart: execution-dialog
-    # https://docs.olivetin.app/action_execution/oncron.html — second example;
-    # the "date" action uses @hourly elsewhere in this file.
-    execOnCron:
-      - "0 3 * * 0"
-
-  # This uses `popupOnStart: execution-button` to display a mini button that
-  # links to the logs.
-  #
   # You can also rate-limit actions too.
-  - title: date
-    shell: date
-    id: date
-    timeout: 6
-    icon: clock
-    popupOnStart: execution-button
+  - title: Sync Disks
+    shell: sync
+    id: syncdisks
+    icon: disk
+    onclick: execution-button
     maxRate:
       - limit: 3
         duration: 1m
-    execOnCron:
-      - "@hourly"
 
   # You are not limited to operating system commands, and of course you can run
-  # your own scripts. Here `maxConcurrent` stops the script running multiple
-  # times in parallel. There is also a timeout that will kill the command if it
-  # runs for too long.
+  # your own scripts. The backup-jobs action group limits how many backup-related
+  # actions can run at once; extra requests are queued instead of blocked.
+  # There is also a timeout that will kill the command if it runs for too long.
   - title: Run backup script
     shell: /opt/backupScript.sh
     shellAfterCompleted: "apprise -t 'Notification: Backup script completed' -b 'The backup script completed with code {{ exitCode}}. The log is: \n {{ output }} '"
-    maxConcurrent: 1
+    groups: [ backup-jobs ]
     timeout: 10
     icon: backup
-    popupOnStart: execution-dialog
+    onclick: execution-dialog
     # https://docs.olivetin.app/action_execution/oncalendar.html
     execOnCalendarFile: examples/demo-olivetin-calendar.yaml
 
+  - title: Verify backup archive
+    shell: sleep 3 && echo "Backup archive verified"
+    groups: [ backup-jobs ]
+    timeout: 30
+    icon: backup
+    onclick: execution-dialog
+
   # When you want to prompt users for input, that is when you should use
   # `arguments` - this presents a popup dialog and asks for argument values.
   #
@@ -91,7 +91,7 @@ actions:
     shell: ping {{ host }} -c {{ count }}
     icon: ping
     timeout: 100
-    popupOnStart: execution-dialog-stdout-only
+    onclick: history
     # https://docs.olivetin.app/action_execution/onwebhook.html — POST to /webhooks
     # with header X-OliveTin-Demo: ping-host (path and payload rules are documented).
     execOnWebhook:
@@ -133,7 +133,8 @@ actions:
   # Docs: https://docs.olivetin.app/args/input_confirmation.html
   - title: Delete old backups
     icon: ashtonished
-    shell: rm -rf /opt/oldBackups/
+    justification: true
+    shell: rm -rf /opt/oliveTinOldBackups/ && sleep 5
     arguments:
       - type: html
         title: Description
@@ -147,7 +148,7 @@ actions:
   #
   # Docs: https://docs.olivetin.app/reference/reference_themes_for_users.html
   - title: Get OliveTin Theme
-    exec: 
+    exec:
       - "olivetin-get-theme"
       - "{{ themeGitRepo }}"
       - "{{ themeFolderName }}"
@@ -171,7 +172,7 @@ actions:
   - title: "Setup easy SSH"
     icon: ssh
     shell: olivetin-setup-easy-ssh
-    popupOnStart: execution-dialog
+    onclick: execution-dialog
     # Second webhook example: POST /webhooks?demo=setup-ssh
     execOnWebhook:
       - matchQuery:
@@ -188,13 +189,6 @@ actions:
     timeout: 1
     shell: ssh -F /config/ssh/easy.cfg root@server1 'service httpd restart'
 
-  # Lots of people use OliveTin to build web interfaces for their electronics
-  # projects. It's best to install OliveTin as a native package (eg, .deb), and
-  # then you can use either a python script or the `gpio` command.
-  - title: Toggle GPIO light
-    shell: gpioset gpiochip1 9=1
-    icon: light
-
   # There are several built-in shortcuts for the `icon` option, but you
   # can also just specify any HTML, this includes any unicode character,
   # or a <img = "..." /> link to a custom icon.
@@ -259,6 +253,15 @@ actions:
     entity: container
     triggers: ["Update container entity file"]
 
+  - title: Long running action
+    shell: sleep 300
+    timeout: 300
+    icon: logs
+    onclick: execution-dialog
+    groups: [ con2queue10 ]
+    execOnCron:
+      - "@hourly"
+
   # Lastly, you can hide actions from the web UI, this is useful for creating
   # background helpers that execute only on startup or a cron, for updating
   # entity files.
@@ -300,6 +303,17 @@ entities:
   - file: entities/containers.json
     name: container
 
+# Action groups share a concurrency limit across multiple actions. When the
+# limit is reached, additional requests are queued and run in order.
+# Docs: https://docs.olivetin.app/action_customization/concurrency.html#action-groups
+actionGroups:
+  backup-jobs:
+    maxConcurrent: 1
+    icon: backup
+  con2queue10:
+    maxConcurrent: 2
+    queueSize: 10
+
 # Dashboards are a way of taking actions from the default "actions" view, and
 # organizing them into groups - either into folders, or fieldsets.
 #
@@ -372,7 +386,7 @@ dashboards:
 
 # Security - Authentication
 
-# This setting effectively enables or disables guests. 
+# This setting effectively enables or disables guests.
 # If set to "true", then users will have to login to do anything.
 authRequireGuestsToLogin: false
 
@@ -381,7 +395,7 @@ authRequireGuestsToLogin: false
 # and JWT authentication which are documented separately.
 #
 # Docs: https://docs.olivetin.app/security/local.html
-# 
+#
 # How to get a hashed password:
 # Docs: https://docs.olivetin.app/security/local.html#_get_a_argon2id_hashed_password
 authLocalUsers:

+ 31 - 0
docs/modules/ROOT/examples/solutions/human-in-the-control-loop/config.yaml

@@ -0,0 +1,31 @@
+---
+logLevel: "WARN"
+checkForUpdates: false
+showFooter: false
+
+actions:
+  - title: Pump ON - 5m
+    id: pump_on_5m
+    icon: restart
+    shell: |
+      echo "Pump started"
+      sleep 300
+    triggers:
+      - Update Water Level
+
+  - title: Update Water Level
+    id: update_water_level
+    shell: echo "Water level 47%"
+    hidden: true
+    execOnStartup: true
+    execOnCron: "*/1 * * * *"
+
+dashboards:
+  - title: Human in the Control Loop
+    contents:
+      - title: Water tank
+        type: fieldset
+        contents:
+          - type: stdout-most-recent-execution
+            title: update_water_level
+          - title: Pump ON - 5m

+ 2 - 0
docs/modules/ROOT/images/.gitignore

@@ -0,0 +1,2 @@
+**/custom-webui
+**/__pycache__

+ 163 - 0
docs/modules/ROOT/images/SCREENSHOTS.md

@@ -0,0 +1,163 @@
+# Documentation screenshots
+
+Use [repo-helper](https://github.com/jamesread/repo-common) (`repo-helper screenshot`) to keep Antora doc images up to date. Each documented UI feature gets its own folder under `docs/modules/ROOT/images/`.
+
+Reference implementation: `args/suggestions/`.
+
+## Folder layout
+
+Create one folder per doc page (or logical screenshot group):
+
+```
+docs/modules/ROOT/images/<topic>/<name>/
+├── screenshots.ini      # batch capture config for repo-helper
+├── config.yaml          # minimal OliveTin config for this screenshot only
+├── setup_<variant>.py   # Selenium setup script(s); each defines run(driver)
+├── Makefile             # start OliveTin, capture, stop
+├── .gitignore           # runtime artifacts (see below)
+└── *.png                # output images (committed)
+```
+
+Wire images in the matching `.adoc` page:
+
+```asciidoc
+image::<topic>/<name>/my-screenshot.png[]
+```
+
+Paths are relative to `docs/modules/ROOT/images/`.
+
+## Port and OliveTin instance
+
+- Doc screenshots use a **dedicated port** (not 1337) so they do not clash with a dev server.
+- All screenshot folders share port **11337**.
+- Set the same port in `config.yaml` (`listenAddressSingleHTTPFrontend`) and `screenshots.ini` (`base_url`).
+- Start OliveTin from `service/` so the webui is found:
+
+  ```bash
+  cd service && ./OliveTin -configdir /path/to/screenshot/folder/
+  ```
+
+The Makefile handles start/wait/capture/stop.
+
+## screenshots.ini
+
+Each `[section]` with a `url` is one PNG. Section `name` (or section title) becomes the filename (`name.png`).
+
+```ini
+[DEFAULT]
+base_url = http://localhost:11337/
+dir = .
+width = 640
+height = 480
+post_script_sleep = 0.5
+
+[my-screenshot]
+url = .
+name = my-screenshot
+script = setup_my_screenshot.py
+```
+
+Notes:
+
+- `--config` must point at the real `screenshots.ini` in the folder; relative paths (`script`, `dir`) resolve from the INI directory.
+- `url = .` loads the dashboard at `base_url`.
+- Override `width`, `height`, `script`, etc. per section when needed.
+
+Capture:
+
+```bash
+cd docs/modules/ROOT/images/<topic>/<name>
+make update-screenshots
+# or, if OliveTin is already running on that port:
+repo-helper screenshot --config screenshots.ini
+```
+
+## config.yaml
+
+Keep configs **minimal**: only actions, dashboards, and settings required for the screenshot.
+
+- Match YAML examples shown in the doc page.
+- Disable noise: `checkForUpdates: false`, `showFooter: false`, `logLevel: "WARN"`.
+- Set argument `type` explicitly to avoid startup warnings.
+- Omit `icon` on actions unless the screenshot needs a specific glyph. OliveTin 3k applies a default action icon (`defaultIconForActions`, currently the neutral CLI glyph) when `icon` is not set.
+
+Reuse integration-test patterns where possible (`integration-tests/tests/*/config.yaml`).
+
+## Setup scripts (Python)
+
+repo-helper loads each `--script` file and calls `run(driver)`. Scripts run **in isolation** (no imports from sibling modules unless you add `sys.path` yourself); prefer one self-contained file per variant.
+
+UI setup should mirror integration tests (`integration-tests/lib/elements.js`):
+
+1. Wait for `body[loaded-dashboard]` before clicking actions.
+2. Click action buttons via `[title="Action Title"]` or `.action-button button`.
+3. For argument forms, wait for `body[loaded-argument-form]`.
+4. Use `#argument-popup`, input ids (`#container`), etc.
+
+Example skeleton:
+
+```python
+import time
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+def run(driver):
+    WebDriverWait(driver, 15).until(
+        lambda d: d.find_element(By.TAG_NAME, "body").get_attribute("loaded-dashboard")
+    )
+    driver.find_element(By.CSS_SELECTOR, '[title="My Action"]').click()
+    WebDriverWait(driver, 15).until(
+        lambda d: d.find_element(By.TAG_NAME, "body").get_attribute("loaded-argument-form")
+    )
+    # optional: frame the form, open menus, inject overlays — see args/suggestions/
+    time.sleep(0.2)
+```
+
+### Headless Chrome limitations
+
+repo-helper uses headless Chrome only. Native browser UI (e.g. `<datalist>` dropdowns, date pickers) often **does not appear** in screenshots. When needed, use `driver.execute_script(...)` in the setup script to render a representative overlay after opening the real form. See `args/suggestions/setup_chrome.py` and `setup_firefox.py`.
+
+## Makefile template
+
+Each screenshot folder needs only a thin `Makefile` that sets `CONFIGDIR` and includes the shared rules:
+
+```makefile
+CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+include ../../screenshots.mk
+```
+
+Shared targets (defined in `docs/modules/ROOT/images/screenshots.mk`):
+
+- `make start` — background OliveTin with this folder's `config.yaml`
+- `make` or `make update-screenshots` — stop any instance on 11337, start, run `repo-helper screenshot --config screenshots.ini`, stop
+- `make stop` — kill whatever is listening on port 11337
+
+## .gitignore (per folder)
+
+```
+custom-webui/
+__pycache__/
+```
+
+## Checklist for a new doc screenshot
+
+1. Create `docs/modules/ROOT/images/<topic>/<name>/` with the files above.
+2. Add `config.yaml` that reproduces the doc example in the UI.
+3. Write `setup_*.py` to reach the desired UI state; test selectors against the Vue UI.
+4. Add sections to `screenshots.ini`; output PNG names match what the `.adoc` will reference.
+5. Update the `.adoc` page: `image::<topic>/<name>/<png>[]`.
+6. Run `make update-screenshots` and commit PNGs plus config/scripts.
+7. Remove obsolete PNGs from `images/` if paths moved.
+
+## Prompt template (for agents)
+
+Use or adapt this when asking to add or refresh doc screenshots:
+
+> Update the documentation screenshot(s) for `<page.adoc>`.
+>
+> - Put everything in `docs/modules/ROOT/images/<topic>/<name>/`: `screenshots.ini`, `config.yaml`, setup script(s), `Makefile`, `.gitignore`, and output PNGs.
+> - Follow `docs/modules/ROOT/images/SCREENSHOTS.md` and copy structure from `docs/modules/ROOT/images/args/suggestions/`.
+> - Use a dedicated OliveTin port (not 1337); start from `service/` with `-configdir` pointing at the screenshot folder.
+> - Setup scripts should wait for `loaded-dashboard` / `loaded-argument-form` like integration tests; reuse selectors from `integration-tests/lib/elements.js` where applicable.
+> - Update `image::` paths in the `.adoc` page to match the new folder.
+> - Run `make update-screenshots` and verify the PNGs before finishing.

+ 2 - 0
docs/modules/ROOT/images/action_buttons/create_your_first/.gitignore

@@ -0,0 +1,2 @@
+custom-webui/
+__pycache__/

+ 2 - 0
docs/modules/ROOT/images/action_buttons/create_your_first/Makefile

@@ -0,0 +1,2 @@
+CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+include ../../screenshots.mk

+ 12 - 0
docs/modules/ROOT/images/action_buttons/create_your_first/config.yaml

@@ -0,0 +1,12 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:11337
+
+logLevel: "WARN"
+checkForUpdates: false
+showFooter: false
+
+actions:
+  - title: Say Hello
+    shell: echo "Hello World!"
+    icon: smile
+    onclick: execution-dialog

BIN
docs/modules/ROOT/images/action_buttons/create_your_first/hello-world.png


+ 11 - 0
docs/modules/ROOT/images/action_buttons/create_your_first/screenshots.ini

@@ -0,0 +1,11 @@
+[DEFAULT]
+base_url = http://localhost:11337/
+dir = .
+width = 720
+height = 480
+post_script_sleep = 0.5
+
+[hello-world]
+url = .
+name = hello-world
+script = setup_hello.py

+ 20 - 0
docs/modules/ROOT/images/action_buttons/create_your_first/setup_hello.py

@@ -0,0 +1,20 @@
+#!/usr/bin/env python3
+"""Show the Say Hello action on the default dashboard."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def run(driver):
+    WebDriverWait(driver, 30).until(
+        lambda d: d.execute_script("return !!window.client")
+    )
+    WebDriverWait(driver, 30).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute("loaded-dashboard"))
+    )
+    WebDriverWait(driver, 30).until(
+        lambda d: d.find_element(By.CSS_SELECTOR, '[title="Say Hello"]').is_displayed()
+    )
+    time.sleep(0.2)

+ 2 - 0
docs/modules/ROOT/images/action_buttons/layout/.gitignore

@@ -0,0 +1,2 @@
+custom-webui/
+__pycache__/

+ 2 - 0
docs/modules/ROOT/images/action_buttons/layout/Makefile

@@ -0,0 +1,2 @@
+CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+include ../../screenshots.mk

+ 37 - 0
docs/modules/ROOT/images/action_buttons/layout/config.yaml

@@ -0,0 +1,37 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:11337
+
+logLevel: "WARN"
+checkForUpdates: false
+showFooter: false
+
+actionGroups:
+  jobs:
+    maxConcurrent: 1
+    queueSize: 5
+
+actions:
+  - title: Restart service
+    icon: restart
+    onclick: execution-dialog
+    shell: echo "Service restarted"
+
+  - title: Long task
+    shell: sleep 120
+    timeout: 300
+    groups: [jobs]
+
+  - title: Backup job
+    shell: sleep 120
+    timeout: 300
+    groups: [jobs]
+
+dashboards:
+  - title: Action button layout
+    contents:
+      - title: Examples
+        type: fieldset
+        contents:
+          - title: Restart service
+          - title: Long task
+          - title: Backup job

BIN
docs/modules/ROOT/images/action_buttons/layout/layout.png


+ 11 - 0
docs/modules/ROOT/images/action_buttons/layout/screenshots.ini

@@ -0,0 +1,11 @@
+[DEFAULT]
+base_url = http://localhost:11337/
+dir = .
+width = 980
+height = 420
+post_script_sleep = 0.5
+
+[layout]
+url = /dashboards/Action%20button%20layout
+name = layout
+script = setup_layout.py

+ 83 - 0
docs/modules/ROOT/images/action_buttons/layout/setup_layout.py

@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""Show action buttons in idle, running, and queued states."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+_START_ACTION_JS = """
+const done = arguments[arguments.length - 1];
+const title = arguments[0];
+
+function bindingIdForTitle(actionTitle) {
+  const button = document.querySelector('[title="' + actionTitle + '"]');
+  if (!button) {
+    throw new Error('Action button not found: ' + actionTitle);
+  }
+  return button.closest('.action-button').id.replace('actionButton-', '');
+}
+
+function uniqueTrackingId() {
+  if (window.isSecureContext && window.crypto?.randomUUID) {
+    return window.crypto.randomUUID();
+  }
+  return 'doc-screenshot-' + Date.now() + '-' + Math.random();
+}
+
+window.client.startAction({
+  bindingId: bindingIdForTitle(title),
+  arguments: [],
+  uniqueTrackingId: uniqueTrackingId(),
+}).then(() => done(true)).catch((err) => done(String(err)));
+"""
+
+
+def _wait_for_dashboard(driver, timeout=30):
+    WebDriverWait(driver, timeout).until(
+        lambda d: d.execute_script("return !!window.client")
+    )
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute("loaded-dashboard"))
+    )
+
+
+def _start_action(driver, title):
+    driver.execute_async_script(_START_ACTION_JS, title)
+
+
+def _wait_for_layout_states(driver, timeout=20):
+    def ready(d):
+        try:
+            restart = d.find_element(By.CSS_SELECTOR, '[title="Restart service"]')
+            running = d.find_element(
+                By.CSS_SELECTOR,
+                '[title="Long task"]'
+            ).find_element(By.XPATH, './ancestor::div[contains(@class, "action-button")]//span[contains(@class, "execution-indicator-running")]')
+            queued = d.find_element(
+                By.CSS_SELECTOR,
+                '[title="Backup job"]'
+            ).find_element(By.XPATH, './ancestor::div[contains(@class, "action-button")]//span[contains(@class, "execution-indicator-queued")]')
+            onclick = d.find_element(
+                By.CSS_SELECTOR,
+                '[title="Restart service"] .navigate-on-start',
+            )
+        except Exception:
+            return False
+        return all(
+            element.is_displayed()
+            for element in (restart, running, queued, onclick)
+        )
+
+    WebDriverWait(driver, timeout).until(ready)
+
+
+def run(driver):
+    _wait_for_dashboard(driver)
+
+    _start_action(driver, "Long task")
+    time.sleep(0.3)
+    _start_action(driver, "Backup job")
+
+    _wait_for_layout_states(driver)
+    time.sleep(0.2)

+ 2 - 0
docs/modules/ROOT/images/action_customization/execution-dialog/.gitignore

@@ -0,0 +1,2 @@
+custom-webui/
+__pycache__/

+ 2 - 0
docs/modules/ROOT/images/action_customization/execution-dialog/Makefile

@@ -0,0 +1,2 @@
+CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+include ../../screenshots.mk

+ 17 - 0
docs/modules/ROOT/images/action_customization/execution-dialog/config.yaml

@@ -0,0 +1,17 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:11337
+
+logLevel: "WARN"
+checkForUpdates: false
+showFooter: false
+
+actions:
+  - title: Check dmesg logs
+    icon: logs
+    onclick: execution-dialog
+    shell: |
+      echo "[    0.000000] Linux version 6.8.7-100.fc38.x86_64 (mock build) #1 SMP PREEMPT_DYNAMIC"
+      echo "[    0.123456] Command line: BOOT_IMAGE=/vmlinuz root=UUID=..."
+      echo "[    1.234567] systemd[1]: Started OliveTin documentation screenshot service."
+      echo "[    1.456789] eth0: renamed from enp0s3"
+      echo "[    2.012345] IPv6: ADDRCONF(NETDEV_CHANGE): eth0: link becomes ready"

BIN
docs/modules/ROOT/images/action_customization/execution-dialog/executionDialog.png


+ 11 - 0
docs/modules/ROOT/images/action_customization/execution-dialog/screenshots.ini

@@ -0,0 +1,11 @@
+[DEFAULT]
+base_url = http://localhost:11337/
+dir = .
+width = 900
+height = 620
+post_script_sleep = 0.5
+
+[execution-dialog]
+url = .
+name = executionDialog
+script = setup_execution_dialog.py

+ 45 - 0
docs/modules/ROOT/images/action_customization/execution-dialog/setup_execution_dialog.py

@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+"""Open the execution-dialog view for Check dmesg logs."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def _wait_for_body_attr(driver, attr, timeout=15):
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute(attr))
+    )
+
+
+def _wait_for_logs_page(driver, timeout=15):
+    WebDriverWait(driver, timeout).until(
+        lambda d: "/logs/" in d.current_url and not d.current_url.rstrip("/").endswith("/logs")
+    )
+
+
+def _wait_for_execution_complete(driver, timeout=15):
+    def finished(d):
+        try:
+            status = d.find_element(By.CSS_SELECTOR, ".execution-dialog-status").text
+        except Exception:
+            return False
+        return "Still running" not in status and "Queued" not in status
+
+    WebDriverWait(driver, timeout).until(finished)
+
+
+def run(driver):
+    _wait_for_body_attr(driver, "loaded-dashboard")
+
+    driver.find_element(By.CSS_SELECTOR, '[title="Check dmesg logs"]').click()
+
+    _wait_for_logs_page(driver)
+    _wait_for_execution_complete(driver)
+
+    WebDriverWait(driver, 15).until(
+        lambda d: d.find_element(By.CSS_SELECTOR, "#execution-results-popup .xterm-rows").text.strip() != ""
+    )
+
+    time.sleep(0.2)

+ 2 - 0
docs/modules/ROOT/images/action_customization/timeout-logs/.gitignore

@@ -0,0 +1,2 @@
+custom-webui/
+__pycache__/

+ 2 - 0
docs/modules/ROOT/images/action_customization/timeout-logs/Makefile

@@ -0,0 +1,2 @@
+CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+include ../../screenshots.mk

+ 11 - 0
docs/modules/ROOT/images/action_customization/timeout-logs/config.yaml

@@ -0,0 +1,11 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:11337
+
+logLevel: "WARN"
+checkForUpdates: false
+showFooter: false
+
+actions:
+  - title: Slow action
+    icon: clock
+    shell: sleep 5

+ 11 - 0
docs/modules/ROOT/images/action_customization/timeout-logs/screenshots.ini

@@ -0,0 +1,11 @@
+[DEFAULT]
+base_url = http://localhost:11337/
+dir = .
+width = 900
+height = 420
+post_script_sleep = 0.5
+
+[timeout-logs]
+url = .
+name = timeoutLogs
+script = setup_timeout_logs.py

+ 42 - 0
docs/modules/ROOT/images/action_customization/timeout-logs/setup_timeout_logs.py

@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+"""Show a timed-out action on the logs page."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def _wait_for_body_attr(driver, attr, timeout=15):
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute(attr))
+    )
+
+
+def _wait_for_timed_out_log(driver, timeout=20):
+    def has_timed_out_row(d):
+        try:
+            status = d.find_element(By.CSS_SELECTOR, ".logs-table .status-timeout").text
+        except Exception:
+            return False
+        return "Timed out" in status
+
+    WebDriverWait(driver, timeout).until(has_timed_out_row)
+
+
+def run(driver):
+    _wait_for_body_attr(driver, "loaded-dashboard")
+
+    driver.find_element(By.CSS_SELECTOR, '[title="Slow action"]').click()
+
+    # Default timeout is 3 seconds; the action sleeps for 5.
+    time.sleep(5)
+
+    driver.execute_script("window.location.href = '/logs'")
+
+    WebDriverWait(driver, 15).until(
+        lambda d: d.find_elements(By.CSS_SELECTOR, ".logs-table tbody tr")
+    )
+    _wait_for_timed_out_log(driver)
+
+    time.sleep(0.2)

BIN
docs/modules/ROOT/images/action_customization/timeout-logs/timeoutLogs.png


BIN
docs/modules/ROOT/images/arg-suggestions-chrome.png


BIN
docs/modules/ROOT/images/arg-suggestions-firefox.png


+ 2 - 0
docs/modules/ROOT/images/args/input/.gitignore

@@ -0,0 +1,2 @@
+custom-webui/
+__pycache__/

+ 2 - 0
docs/modules/ROOT/images/args/input/Makefile

@@ -0,0 +1,2 @@
+CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+include ../../screenshots.mk

BIN
docs/modules/ROOT/images/args/input/args1.png


BIN
docs/modules/ROOT/images/args/input/args2.png


BIN
docs/modules/ROOT/images/args/input/args3.png


+ 16 - 0
docs/modules/ROOT/images/args/input/config.yaml

@@ -0,0 +1,16 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:11337
+
+logLevel: "WARN"
+checkForUpdates: false
+showFooter: false
+
+actions:
+  - title: Print a message
+    shell: echo {{ message }}
+    arguments:
+      - name: message
+        description: The message you want to print out on the shell.
+        title: Your Message
+        default: Hello World
+        type: ascii_sentence

+ 21 - 0
docs/modules/ROOT/images/args/input/screenshots.ini

@@ -0,0 +1,21 @@
+[DEFAULT]
+base_url = http://localhost:11337/
+dir = .
+width = 800
+height = 480
+post_script_sleep = 0.5
+
+[args1]
+url = .
+name = args1
+script = setup_args1.py
+
+[args2]
+url = .
+name = args2
+script = setup_args2.py
+
+[args3]
+url = .
+name = args3
+script = setup_args3.py

+ 19 - 0
docs/modules/ROOT/images/args/input/setup_args1.py

@@ -0,0 +1,19 @@
+#!/usr/bin/env python3
+"""Prepare the dashboard action-button screenshot."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def _wait_for_body_attr(driver, attr, timeout=15):
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute(attr))
+    )
+
+
+def run(driver):
+    _wait_for_body_attr(driver, "loaded-dashboard")
+
+    time.sleep(0.2)

+ 36 - 0
docs/modules/ROOT/images/args/input/setup_args2.py

@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+"""Prepare the argument-form screenshot."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def _wait_for_body_attr(driver, attr, timeout=15):
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute(attr))
+    )
+
+
+def _open_form(driver):
+    _wait_for_body_attr(driver, "loaded-dashboard")
+
+    action = driver.find_element(By.CSS_SELECTOR, '[title="Print a message"]')
+    action.click()
+
+    _wait_for_body_attr(driver, "loaded-argument-form")
+
+
+def run(driver):
+    _open_form(driver)
+
+    driver.execute_script(
+        """
+        const input = document.getElementById('message');
+        if (input && !input.value) {
+          input.value = 'Hello World';
+        }
+        """
+    )
+    time.sleep(0.2)

+ 69 - 0
docs/modules/ROOT/images/args/input/setup_args3.py

@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""Prepare the execution-results screenshot."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def _wait_for_body_attr(driver, attr, timeout=15):
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute(attr))
+    )
+
+
+def _wait_for_logs_page(driver, timeout=15):
+    WebDriverWait(driver, timeout).until(
+        lambda d: "/logs/" in d.current_url and not d.current_url.rstrip("/").endswith("/logs")
+    )
+
+
+def _wait_for_execution_complete(driver, timeout=15):
+    def finished(d):
+        try:
+            status = d.find_element(By.CSS_SELECTOR, ".execution-dialog-status").text
+        except Exception:
+            return False
+        return "Still running" not in status and "Queued" not in status
+
+    WebDriverWait(driver, timeout).until(finished)
+
+
+def _start_action_and_open_logs(driver, timeout=15):
+    WebDriverWait(driver, timeout).until(
+        lambda d: d.execute_script("return !!window.client")
+    )
+    WebDriverWait(driver, timeout).until(
+        lambda d: d.find_element(By.CSS_SELECTOR, 'button[name="start"]').is_enabled()
+    )
+
+    driver.execute_async_script(
+        """
+        const done = arguments[arguments.length - 1];
+        const bindingId = document.body.getAttribute('loaded-argument-form');
+        window.client.startAction({
+          bindingId: bindingId,
+          arguments: [{ name: 'message', value: 'Hello World' }],
+          uniqueTrackingId: 'doc-screenshot-' + Date.now(),
+        }).then((response) => {
+          window.location.href = '/logs/' + response.executionTrackingId;
+          done(true);
+        }).catch((err) => done('error: ' + err));
+        """
+    )
+
+
+def run(driver):
+    _wait_for_body_attr(driver, "loaded-dashboard")
+
+    action = driver.find_element(By.CSS_SELECTOR, '[title="Print a message"]')
+    action.click()
+
+    _wait_for_body_attr(driver, "loaded-argument-form")
+    _start_action_and_open_logs(driver)
+
+    _wait_for_logs_page(driver)
+    _wait_for_execution_complete(driver)
+
+    time.sleep(0.2)

+ 2 - 0
docs/modules/ROOT/images/args/suggestions/.gitignore

@@ -0,0 +1,2 @@
+custom-webui/
+__pycache__/

+ 2 - 0
docs/modules/ROOT/images/args/suggestions/Makefile

@@ -0,0 +1,2 @@
+CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+include ../../screenshots.mk

BIN
docs/modules/ROOT/images/args/suggestions/arg-suggestions-chrome.png


BIN
docs/modules/ROOT/images/args/suggestions/arg-suggestions-firefox.png


+ 21 - 0
docs/modules/ROOT/images/args/suggestions/config.yaml

@@ -0,0 +1,21 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:11337
+
+logLevel: "WARN"
+checkForUpdates: false
+showFooter: false
+
+actions:
+  - title: Restart Docker Container
+    icon: restart
+    shell: "echo 'Restarting container: {{ container }}'"
+    arguments:
+      - name: container
+        title: Container name
+        type: ascii_identifier
+        suggestions:
+          plex:
+          graefik:
+          grafana:
+          wifi-controller: WiFi Controller
+          firewall-controller: Firewall Controller

+ 16 - 0
docs/modules/ROOT/images/args/suggestions/screenshots.ini

@@ -0,0 +1,16 @@
+[DEFAULT]
+base_url = http://localhost:11337/
+dir = .
+width = 800
+height = 480
+post_script_sleep = 0.5
+
+[arg-suggestions-chrome]
+url = .
+name = arg-suggestions-chrome
+script = setup_chrome.py
+
+[arg-suggestions-firefox]
+url = .
+name = arg-suggestions-firefox
+script = setup_firefox.py

+ 93 - 0
docs/modules/ROOT/images/args/suggestions/setup_chrome.py

@@ -0,0 +1,93 @@
+#!/usr/bin/env python3
+"""Prepare the Chrome-style suggestions screenshot."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def _wait_for_body_attr(driver, attr, timeout=15):
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute(attr))
+    )
+
+
+def _open_form(driver):
+    _wait_for_body_attr(driver, "loaded-dashboard")
+
+    action = driver.find_element(
+        By.CSS_SELECTOR, '[title="Restart Docker Container"]'
+    )
+    action.click()
+
+    _wait_for_body_attr(driver, "loaded-argument-form")
+
+    driver.execute_script(
+        """
+        const form = document.getElementById('argument-popup');
+        if (form) {
+          form.style.margin = '2rem auto';
+          form.style.maxWidth = '520px';
+        }
+        """
+    )
+
+
+def run(driver):
+    _open_form(driver)
+
+    driver.execute_script(
+        """
+        const input = document.getElementById('container');
+        input.focus();
+        input.value = '';
+
+        document.getElementById('doc-suggestions-overlay')?.remove();
+
+        const rect = input.getBoundingClientRect();
+        const menu = document.createElement('div');
+        menu.id = 'doc-suggestions-overlay';
+        menu.style.position = 'fixed';
+        menu.style.left = `${rect.left}px`;
+        menu.style.top = `${rect.bottom + 2}px`;
+        menu.style.width = `${rect.width}px`;
+        menu.style.background = '#fff';
+        menu.style.border = '1px solid #888';
+        menu.style.boxShadow = '0 2px 6px rgba(0, 0, 0, 0.2)';
+        menu.style.font = '13px sans-serif';
+        menu.style.zIndex = '9999';
+
+        const items = [
+          ['firewall-controller', 'Firewall Controller'],
+          ['graefik', ''],
+          ['grafana', ''],
+          ['plex', ''],
+          ['wifi-controller', 'WiFi Controller'],
+        ];
+
+        for (const [value, label] of items) {
+          const row = document.createElement('div');
+          row.style.padding = '4px 8px';
+          row.style.lineHeight = '1.3';
+
+          const valueEl = document.createElement('div');
+          valueEl.textContent = value;
+          valueEl.style.fontWeight = label ? '600' : '400';
+          row.appendChild(valueEl);
+
+          if (label) {
+            const labelEl = document.createElement('div');
+            labelEl.textContent = label;
+            labelEl.style.color = '#666';
+            labelEl.style.fontSize = '12px';
+            row.appendChild(labelEl);
+          }
+
+          menu.appendChild(row);
+        }
+
+        document.body.appendChild(menu);
+        """
+    )
+    time.sleep(0.2)

+ 77 - 0
docs/modules/ROOT/images/args/suggestions/setup_firefox.py

@@ -0,0 +1,77 @@
+#!/usr/bin/env python3
+"""Prepare the Firefox-style suggestions screenshot."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def _wait_for_body_attr(driver, attr, timeout=15):
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute(attr))
+    )
+
+
+def _open_form(driver):
+    _wait_for_body_attr(driver, "loaded-dashboard")
+
+    action = driver.find_element(
+        By.CSS_SELECTOR, '[title="Restart Docker Container"]'
+    )
+    action.click()
+
+    _wait_for_body_attr(driver, "loaded-argument-form")
+
+    driver.execute_script(
+        """
+        const form = document.getElementById('argument-popup');
+        if (form) {
+          form.style.margin = '2rem auto';
+          form.style.maxWidth = '520px';
+        }
+        """
+    )
+
+
+def run(driver):
+    _open_form(driver)
+
+    driver.execute_script(
+        """
+        const input = document.getElementById('container');
+        input.focus();
+        input.value = '';
+
+        document.getElementById('doc-suggestions-overlay')?.remove();
+
+        const rect = input.getBoundingClientRect();
+        const menu = document.createElement('div');
+        menu.id = 'doc-suggestions-overlay';
+        menu.style.position = 'fixed';
+        menu.style.left = `${rect.left}px`;
+        menu.style.top = `${rect.bottom + 2}px`;
+        menu.style.width = `${rect.width}px`;
+        menu.style.background = '#fff';
+        menu.style.border = '1px solid #ccc';
+        menu.style.boxShadow = '0 1px 4px rgba(0, 0, 0, 0.15)';
+        menu.style.font = '13px sans-serif';
+        menu.style.zIndex = '9999';
+
+        for (const label of [
+          'Firewall Controller',
+          'graefik',
+          'grafana',
+          'plex',
+          'WiFi Controller',
+        ]) {
+          const row = document.createElement('div');
+          row.textContent = label;
+          row.style.padding = '4px 8px';
+          menu.appendChild(row);
+        }
+
+        document.body.appendChild(menu);
+        """
+    )
+    time.sleep(0.2)

BIN
docs/modules/ROOT/images/args1.png


BIN
docs/modules/ROOT/images/args2.png


BIN
docs/modules/ROOT/images/args3.png


BIN
docs/modules/ROOT/images/dashboard.png


+ 2 - 0
docs/modules/ROOT/images/dashboards/intro/.gitignore

@@ -0,0 +1,2 @@
+custom-webui/
+__pycache__/

+ 2 - 0
docs/modules/ROOT/images/dashboards/intro/Makefile

@@ -0,0 +1,2 @@
+CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+include ../../screenshots.mk

+ 53 - 0
docs/modules/ROOT/images/dashboards/intro/config.yaml

@@ -0,0 +1,53 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:11337
+
+logLevel: "WARN"
+checkForUpdates: false
+showFooter: false
+
+actions:
+  - title: Ping All Servers
+    icon: ping
+    shell: echo "ping all..."
+
+  - title: Ping hypervisor1
+    icon: ping
+    shell: echo "ping hypervisor1"
+
+  - title: Ping hypervisor2
+    icon: ping
+    shell: echo "ping hypervisor2"
+
+  - title: '{{ server.name }} Wake on Lan'
+    shell: echo "wol {{ server.name }}"
+    entity: server
+
+  - title: '{{ server.name }} Power Off'
+    shell: echo "poweroff {{ server.name }}"
+    entity: server
+
+entities:
+  - file: servers.yaml
+    name: server
+
+dashboards:
+  - title: My Servers
+    contents:
+      - title: All Servers
+        type: fieldset
+        contents:
+          - title: Ping All Servers
+          - title: Hypervisors
+            contents:
+              - title: Ping hypervisor1
+              - title: Ping hypervisor2
+      - type: fieldset
+        entity: server
+        title: 'Server: {{ server.hostname }}'
+        contents:
+          - type: display
+            title: |
+              Hostname: <strong>{{ server.name }}</strong>
+              IP Address: <strong>{{ server.ip }}</strong>
+          - title: '{{ server.name }} Wake on Lan'
+          - title: '{{ server.name }} Power Off'

BIN
docs/modules/ROOT/images/dashboards/intro/preview.png


+ 11 - 0
docs/modules/ROOT/images/dashboards/intro/screenshots.ini

@@ -0,0 +1,11 @@
+[DEFAULT]
+base_url = http://localhost:11337/
+dir = .
+width = 980
+height = 720
+post_script_sleep = 0.5
+
+[preview]
+url = /dashboards/My%20Servers
+name = preview
+script = setup_preview.py

+ 9 - 0
docs/modules/ROOT/images/dashboards/intro/servers.yaml

@@ -0,0 +1,9 @@
+- name: server1
+  hostname: server1.example.com
+  ip: 192.168.0.1
+- name: server2
+  hostname: server2.example.com
+  ip: 192.168.0.2
+- name: server3
+  hostname: server3.example.com
+  ip: 192.168.0.3

+ 42 - 0
docs/modules/ROOT/images/dashboards/intro/setup_preview.py

@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+"""Open the My Servers dashboard from dashboards/intro.adoc."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def _wait_for_dashboard(driver, timeout=30):
+    WebDriverWait(driver, timeout).until(
+        lambda d: d.execute_script("return !!window.client")
+    )
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute("loaded-dashboard"))
+    )
+
+
+def _wait_for_my_servers_dashboard(driver, timeout=30):
+    def ready(d):
+        try:
+            ping_all = d.find_element(By.CSS_SELECTOR, '[title="Ping All Servers"]')
+            hypervisors = d.find_element(
+                By.XPATH,
+                '//button[contains(@class, "directory-button")]//span[contains(@class, "title") and text()="Hypervisors"]',
+            )
+            server1 = d.find_element(By.CSS_SELECTOR, '[title="server1 Wake on Lan"]')
+            server3 = d.find_element(By.CSS_SELECTOR, '[title="server3 Power Off"]')
+        except Exception:
+            return False
+        return all(
+            element.is_displayed()
+            for element in (ping_all, hypervisors, server1, server3)
+        )
+
+    WebDriverWait(driver, timeout).until(ready)
+
+
+def run(driver):
+    _wait_for_dashboard(driver)
+    _wait_for_my_servers_dashboard(driver)
+    time.sleep(0.2)

BIN
docs/modules/ROOT/images/executionDialog.png


BIN
docs/modules/ROOT/images/hello-world.png


+ 2 - 0
docs/modules/ROOT/images/logs/views/.gitignore

@@ -0,0 +1,2 @@
+custom-webui/
+__pycache__/

+ 2 - 0
docs/modules/ROOT/images/logs/views/Makefile

@@ -0,0 +1,2 @@
+CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+include ../../screenshots.mk

+ 31 - 0
docs/modules/ROOT/images/logs/views/config.yaml

@@ -0,0 +1,31 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:11337
+
+logLevel: "WARN"
+checkForUpdates: false
+showFooter: false
+
+actionGroups:
+  backup:
+    maxConcurrent: 1
+    queueSize: 5
+
+actions:
+  - title: Check disk space
+    icon: disk
+    shell: |
+      echo "Filesystem      Size  Used Avail Use% Mounted on"
+      echo "/dev/sda1        50G   12G   38G  24% /"
+
+  - title: Restart service
+    icon: restart
+    shell: echo "Service restarted successfully"
+
+  - title: Slow action
+    icon: clock
+    shell: sleep 5
+
+  - title: Slow backup
+    icon: backup
+    shell: sleep 30
+    groups: [ backup ]

BIN
docs/modules/ROOT/images/logs/views/logsCalendar.png


BIN
docs/modules/ROOT/images/logs/views/logsList.png


BIN
docs/modules/ROOT/images/logs/views/logsQueue.png


+ 24 - 0
docs/modules/ROOT/images/logs/views/screenshots.ini

@@ -0,0 +1,24 @@
+[DEFAULT]
+base_url = http://localhost:11337/
+dir = .
+width = 900
+height = 480
+post_script_sleep = 0.5
+
+[logs-list]
+url = .
+name = logsList
+script = setup_logs_list.py
+height = 460
+
+[logs-calendar]
+url = .
+name = logsCalendar
+script = setup_logs_calendar.py
+height = 640
+
+[logs-queue]
+url = .
+name = logsQueue
+script = setup_logs_queue.py
+height = 520

+ 78 - 0
docs/modules/ROOT/images/logs/views/setup_logs_calendar.py

@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""Open the logs calendar view with executions on the current month."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+_START_ACTIONS_JS = """
+const done = arguments[arguments.length - 1];
+const titles = arguments[0];
+
+function bindingIdForTitle(title) {
+  const button = document.querySelector('[title="' + title + '"]');
+  if (!button) {
+    throw new Error('Action button not found: ' + title);
+  }
+  return button.closest('.action-button').id.replace('actionButton-', '');
+}
+
+function uniqueTrackingId() {
+  if (window.isSecureContext && window.crypto?.randomUUID) {
+    return window.crypto.randomUUID();
+  }
+  return 'doc-screenshot-' + Date.now() + '-' + Math.random();
+}
+
+function startByTitle(title) {
+  return window.client.startAction({
+    bindingId: bindingIdForTitle(title),
+    arguments: [],
+    uniqueTrackingId: uniqueTrackingId(),
+  });
+}
+
+Promise.all(titles.map(startByTitle)).then(() => done(true)).catch((err) => done(String(err)));
+"""
+
+
+def _wait_for_dashboard(driver, timeout=15):
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute("loaded-dashboard"))
+    )
+    WebDriverWait(driver, timeout).until(
+        lambda d: d.execute_script("return !!window.client")
+    )
+
+
+def run(driver):
+    _wait_for_dashboard(driver)
+
+    driver.execute_async_script(
+        _START_ACTIONS_JS,
+        ["Check disk space", "Restart service"],
+    )
+
+    time.sleep(2)
+
+    driver.execute_script("window.location.href = '/logs/calendar'")
+
+    WebDriverWait(driver, 15).until(
+        lambda d: len(d.find_elements(By.CSS_SELECTOR, ".calendar-event")) >= 2
+    )
+
+    driver.execute_script(
+        """
+        const today = new Date();
+        const key = today.getFullYear() + '-'
+          + String(today.getMonth() + 1).padStart(2, '0') + '-'
+          + String(today.getDate()).padStart(2, '0');
+        const cell = document.querySelector('[data-calendar-date="' + key + '"]');
+        if (cell) {
+          cell.scrollIntoView({ block: 'center' });
+        }
+        """
+    )
+
+    time.sleep(0.2)

+ 73 - 0
docs/modules/ROOT/images/logs/views/setup_logs_list.py

@@ -0,0 +1,73 @@
+#!/usr/bin/env python3
+"""Seed log entries and open the logs list view."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+_START_ACTIONS_JS = """
+const done = arguments[arguments.length - 1];
+const titles = arguments[0];
+
+function bindingIdForTitle(title) {
+  const button = document.querySelector('[title="' + title + '"]');
+  if (!button) {
+    throw new Error('Action button not found: ' + title);
+  }
+  return button.closest('.action-button').id.replace('actionButton-', '');
+}
+
+function uniqueTrackingId() {
+  if (window.isSecureContext && window.crypto?.randomUUID) {
+    return window.crypto.randomUUID();
+  }
+  return 'doc-screenshot-' + Date.now() + '-' + Math.random();
+}
+
+function startByTitle(title) {
+  return window.client.startAction({
+    bindingId: bindingIdForTitle(title),
+    arguments: [],
+    uniqueTrackingId: uniqueTrackingId(),
+  });
+}
+
+Promise.all(titles.map(startByTitle)).then(() => done(true)).catch((err) => done(String(err)));
+"""
+
+
+def _wait_for_dashboard(driver, timeout=15):
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute("loaded-dashboard"))
+    )
+    WebDriverWait(driver, timeout).until(
+        lambda d: d.execute_script("return !!window.client")
+    )
+
+
+def _wait_for_logs_table(driver, timeout=15):
+    WebDriverWait(driver, timeout).until(
+        lambda d: len(d.find_elements(By.CSS_SELECTOR, ".logs-table tbody tr")) >= 3
+    )
+
+
+def run(driver):
+    _wait_for_dashboard(driver)
+
+    driver.execute_async_script(
+        _START_ACTIONS_JS,
+        ["Check disk space", "Restart service", "Slow action"],
+    )
+
+    # Slow action uses the default 3 second timeout while sleeping for 5.
+    time.sleep(5)
+
+    driver.execute_script("window.location.href = '/logs'")
+    _wait_for_logs_table(driver)
+
+    WebDriverWait(driver, 15).until(
+        lambda d: d.find_element(By.CSS_SELECTOR, ".logs-table .status-timeout").text.strip() != ""
+    )
+
+    time.sleep(0.2)

+ 69 - 0
docs/modules/ROOT/images/logs/views/setup_logs_queue.py

@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+"""Show queued executions on the logs queue page."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+_QUEUE_ACTIONS_JS = """
+const done = arguments[arguments.length - 1];
+const title = arguments[0];
+const count = arguments[1];
+
+function bindingIdForTitle(actionTitle) {
+  const button = document.querySelector('[title="' + actionTitle + '"]');
+  if (!button) {
+    throw new Error('Action button not found: ' + actionTitle);
+  }
+  return button.closest('.action-button').id.replace('actionButton-', '');
+}
+
+function uniqueTrackingId() {
+  if (window.isSecureContext && window.crypto?.randomUUID) {
+    return window.crypto.randomUUID();
+  }
+  return 'doc-screenshot-' + Date.now() + '-' + Math.random();
+}
+
+async function queueActions() {
+  const bindingId = bindingIdForTitle(title);
+  for (let i = 0; i < count; i++) {
+    await window.client.startAction({
+      bindingId: bindingId,
+      arguments: [],
+      uniqueTrackingId: uniqueTrackingId(),
+    });
+  }
+}
+
+queueActions().then(() => done(true)).catch((err) => done(String(err)));
+"""
+
+
+def _wait_for_dashboard(driver, timeout=15):
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute("loaded-dashboard"))
+    )
+    WebDriverWait(driver, timeout).until(
+        lambda d: d.execute_script("return !!window.client")
+    )
+
+
+def run(driver):
+    _wait_for_dashboard(driver)
+
+    driver.execute_async_script(_QUEUE_ACTIONS_JS, "Slow backup", 3)
+
+    time.sleep(1)
+
+    driver.execute_script("window.location.href = '/logs/queue'")
+
+    WebDriverWait(driver, 15).until(
+        lambda d: len(d.find_elements(By.CSS_SELECTOR, ".queue-action-group-section")) >= 1
+    )
+    WebDriverWait(driver, 15).until(
+        lambda d: len(d.find_elements(By.CSS_SELECTOR, ".queue-position")) >= 1
+    )
+
+    time.sleep(0.2)

+ 49 - 0
docs/modules/ROOT/images/screenshots.mk

@@ -0,0 +1,49 @@
+.PHONY: update-screenshots start stop
+
+ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/../../../..)
+OLIVETIN ?= $(ROOT)/service/OliveTin
+PORT := 11337
+
+ifndef CONFIGDIR
+$(error CONFIGDIR must be set before including screenshots.mk)
+endif
+
+.DEFAULT_GOAL := update-screenshots
+
+start:
+	@set -e; \
+	if curl -sf "http://localhost:$(PORT)/" >/dev/null 2>&1; then \
+		echo "Port $(PORT) is already in use; run 'make stop' first"; \
+		exit 1; \
+	fi; \
+	cd "$(ROOT)/service" && "$(OLIVETIN)" -configdir "$(CONFIGDIR)" & \
+	pid=$$!; \
+	for i in 1 2 3 4 5 6 7 8 9 10; do \
+		if curl -sf "http://localhost:$(PORT)/" >/dev/null; then \
+			exit 0; \
+		fi; \
+		if ! kill -0 $$pid 2>/dev/null; then \
+			echo "OliveTin exited before listening on port $(PORT)"; \
+			exit 1; \
+		fi; \
+		sleep 1; \
+	done; \
+	echo "Timed out waiting for OliveTin on port $(PORT)"; \
+	exit 1
+
+stop:
+	@set +e; \
+	if command -v fuser >/dev/null 2>&1; then \
+		fuser -k $(PORT)/tcp 2>/dev/null; \
+	else \
+		for pid in $$(lsof -t -i :$(PORT) 2>/dev/null); do kill $$pid 2>/dev/null; done; \
+	fi; \
+	for i in 1 2 3 4 5; do \
+		curl -sf "http://localhost:$(PORT)/" >/dev/null || exit 0; \
+		sleep 1; \
+	done; \
+	exit 0
+
+update-screenshots: stop start
+	cd "$(CONFIGDIR)" && repo-helper screenshot --config screenshots.ini
+	@$(MAKE) stop

BIN
docs/modules/ROOT/images/solution-k8s-hosted.png


BIN
docs/modules/ROOT/images/solution-systemd-control-panel.png


+ 2 - 0
docs/modules/ROOT/images/solutions/container-control-panel/.gitignore

@@ -0,0 +1,2 @@
+custom-webui/
+__pycache__/

+ 2 - 0
docs/modules/ROOT/images/solutions/container-control-panel/Makefile

@@ -0,0 +1,2 @@
+CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+include ../../screenshots.mk

+ 34 - 0
docs/modules/ROOT/images/solutions/container-control-panel/config.yaml

@@ -0,0 +1,34 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:11337
+
+logLevel: "WARN"
+checkForUpdates: false
+showFooter: false
+
+actions:
+  - title: Start {{ container.Names }}
+    icon: box
+    shell: echo "start {{ container.Names }}"
+    entity: container
+
+  - title: Stop {{ container.Names }}
+    icon: box
+    shell: echo "stop {{ container.Names }}"
+    entity: container
+
+entities:
+  - file: containers.json
+    name: container
+
+dashboards:
+  - title: My Containers
+    contents:
+      - title: 'Container {{ container.Names }} ({{ container.Image }})'
+        entity: container
+        type: fieldset
+        contents:
+          - type: display
+            title: |
+              {{ container.RunningFor }} <br /><br /><strong>{{ container.State }}</strong>
+          - title: 'Start {{ container.Names }}'
+          - title: 'Stop {{ container.Names }}'

+ 2 - 0
docs/modules/ROOT/images/solutions/container-control-panel/containers.json

@@ -0,0 +1,2 @@
+{"Command":"\"/bin/bash\"","CreatedAt":"2024-02-28 22:33:35 +0000 GMT","ID":"fcf468e18a0e","Image":"fedora","Labels":"maintainer=Clement Verna \u003ccverna@fedoraproject.org\u003e","LocalVolumes":"0","Mounts":"","Names":"minecraft","Networks":"bridge","Ports":"","RunningFor":"3 minutes ago","Size":"0B","State":"created","Status":"Created"}
+{"Command":"\"/bin/bash\"","CreatedAt":"2024-02-23 23:18:57 +0000 GMT","ID":"442dd6fe316a","Image":"fedora","Labels":"maintainer=Clement Verna \u003ccverna@fedoraproject.org\u003e","LocalVolumes":"0","Mounts":"","Names":"brave_shirley","Networks":"bridge","Ports":"","RunningFor":"4 days ago","Size":"0B","State":"created","Status":"Created"}

BIN
docs/modules/ROOT/images/solutions/container-control-panel/preview.png


+ 11 - 0
docs/modules/ROOT/images/solutions/container-control-panel/screenshots.ini

@@ -0,0 +1,11 @@
+[DEFAULT]
+base_url = http://localhost:11337/
+dir = .
+width = 980
+height = 520
+post_script_sleep = 0.5
+
+[preview]
+url = /dashboards/My%20Containers
+name = preview
+script = setup_preview.py

+ 42 - 0
docs/modules/ROOT/images/solutions/container-control-panel/setup_preview.py

@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+"""Open the My Containers dashboard for the container control panel solution."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def _wait_for_dashboard(driver, timeout=30):
+    WebDriverWait(driver, timeout).until(
+        lambda d: d.execute_script("return !!window.client")
+    )
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute("loaded-dashboard"))
+    )
+
+
+def _wait_for_my_containers_dashboard(driver, timeout=30):
+    def ready(d):
+        required_titles = [
+            "Start minecraft",
+            "Stop minecraft",
+            "Start brave_shirley",
+            "Stop brave_shirley",
+        ]
+        for title in required_titles:
+            try:
+                button = d.find_element(By.CSS_SELECTOR, f'[title="{title}"]')
+            except Exception:
+                return False
+            if not button.is_displayed():
+                return False
+        return True
+
+    WebDriverWait(driver, timeout).until(ready)
+
+
+def run(driver):
+    _wait_for_dashboard(driver)
+    _wait_for_my_containers_dashboard(driver)
+    time.sleep(0.2)

+ 2 - 0
docs/modules/ROOT/images/solutions/human-in-the-control-loop/.gitignore

@@ -0,0 +1,2 @@
+custom-webui/
+__pycache__/

+ 2 - 0
docs/modules/ROOT/images/solutions/human-in-the-control-loop/Makefile

@@ -0,0 +1,2 @@
+CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+include ../../screenshots.mk

+ 33 - 0
docs/modules/ROOT/images/solutions/human-in-the-control-loop/config.yaml

@@ -0,0 +1,33 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:11337
+
+logLevel: "WARN"
+checkForUpdates: false
+showFooter: false
+
+actions:
+  - title: Pump ON - 5m
+    id: pump_on_5m
+    icon: restart
+    shell: |
+      echo "Pump started"
+      sleep 300
+    triggers:
+      - Update Water Level
+
+  - title: Update Water Level
+    id: update_water_level
+    shell: echo "Water level 47%"
+    hidden: true
+    execOnStartup: true
+    execOnCron: "*/1 * * * *"
+
+dashboards:
+  - title: Human in the Control Loop
+    contents:
+      - title: Water tank
+        type: fieldset
+        contents:
+          - type: stdout-most-recent-execution
+            title: update_water_level
+          - title: Pump ON - 5m

BIN
docs/modules/ROOT/images/solutions/human-in-the-control-loop/preview.png


+ 11 - 0
docs/modules/ROOT/images/solutions/human-in-the-control-loop/screenshots.ini

@@ -0,0 +1,11 @@
+[DEFAULT]
+base_url = http://localhost:11337/
+dir = .
+width = 900
+height = 420
+post_script_sleep = 0.5
+
+[preview]
+url = /dashboards/Human%20in%20the%20Control%20Loop
+name = preview
+script = setup_preview.py

+ 34 - 0
docs/modules/ROOT/images/solutions/human-in-the-control-loop/setup_preview.py

@@ -0,0 +1,34 @@
+#!/usr/bin/env python3
+"""Open the Human in the Control Loop dashboard with water level output."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+
+def _wait_for_dashboard(driver, timeout=30):
+    WebDriverWait(driver, timeout).until(
+        lambda d: d.execute_script("return !!window.client")
+    )
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute("loaded-dashboard"))
+    )
+
+
+def _wait_for_water_level(driver, timeout=30):
+    def ready(d):
+        try:
+            output = d.find_element(By.CSS_SELECTOR, ".mre-output").text
+            pump = d.find_element(By.CSS_SELECTOR, '[title="Pump ON - 5m"]')
+        except Exception:
+            return False
+        return "Water level 47%" in output and pump.is_displayed()
+
+    WebDriverWait(driver, timeout).until(ready)
+
+
+def run(driver):
+    _wait_for_dashboard(driver)
+    _wait_for_water_level(driver)
+    time.sleep(0.2)

+ 2 - 0
docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/.gitignore

@@ -0,0 +1,2 @@
+custom-webui/
+__pycache__/

+ 2 - 0
docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/Makefile

@@ -0,0 +1,2 @@
+CONFIGDIR := $(dir $(abspath $(lastword $(MAKEFILE_LIST))))
+include ../../screenshots.mk

+ 30 - 0
docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/config.yaml

@@ -0,0 +1,30 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:11337
+
+logLevel: "WARN"
+checkForUpdates: false
+showFooter: false
+
+actions:
+  - title: get pods
+    icon: <iconify-icon icon="pajamas:pod"></iconify-icon>
+    shell: |
+      echo "NAME                          READY   STATUS    RESTARTS   AGE"
+      echo "olivetin-7f8b9c6d4-xk2mp       1/1     Running   0          3d"
+      echo "postgres-5d4f8b7c9-mn8pq        1/1     Running   0          12d"
+      echo "nginx-ingress-controller-2h9k   1/1     Running   0          45d"
+
+  - title: restart postgres deployment
+    icon: <iconify-icon icon="pajamas:clear-all"></iconify-icon>
+    shell: echo "deployment.apps/postgres restarted"
+
+  - title: evacuate node
+    icon: <iconify-icon icon="pajamas:rocket-launch"></iconify-icon>
+    shell: echo "node/{{ NodeName }} cordoned and drained"
+    arguments:
+      - name: NodeName
+        type: ascii_identifier
+        choices:
+          - value: node1
+          - value: node2
+          - value: node3

BIN
docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/preview.png


Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio