فهرست منبع

Merge branch 'next' into wip

James Read 2 هفته پیش
والد
کامیت
d23fb2fd77
100فایلهای تغییر یافته به همراه1673 افزوده شده و 160 حذف شده
  1. 0 82
      .github/dependabot.yml
  2. 45 10
      .github/workflows/build-and-release.yml
  3. 16 4
      .github/workflows/codeql-analysis.yml
  4. 9 4
      .github/workflows/codestyle.yml
  5. 20 0
      .github/workflows/devskim.yml
  6. 44 0
      .github/workflows/docs-antora.yml
  7. 6 1
      .gitignore
  8. 3 1
      .goreleaser.yml
  9. 29 2
      .pre-commit-config.yaml
  10. 14 3
      AGENTS.md
  11. 1 0
      CONTRIBUTING.adoc
  12. 5 3
      Dockerfile.multiarches
  13. 4 2
      Dockerfile.singlearch
  14. 3 0
      README.md
  15. 50 3
      SECURITY.md
  16. 90 45
      config.yaml
  17. 15 0
      docs/antora.yml
  18. 40 0
      docs/modules/ROOT/check_chevron_links.py
  19. 24 0
      docs/modules/ROOT/check_no_h1.py
  20. 25 0
      docs/modules/ROOT/check_unnavigable.py
  21. 15 0
      docs/modules/ROOT/examples/action_customization/icons/config.yaml
  22. 9 0
      docs/modules/ROOT/examples/k8s_configmap.yml
  23. 37 0
      docs/modules/ROOT/examples/k8s_deployment.yml
  24. 21 0
      docs/modules/ROOT/examples/k8s_ingress.yml
  25. 19 0
      docs/modules/ROOT/examples/reverse-proxies/etc/npm-docker-compose.yml
  26. 25 0
      docs/modules/ROOT/examples/reverse-proxies/etc/reverse_proxy_nginx_dns.conf
  27. 48 0
      docs/modules/ROOT/examples/solutions/container-control-panel/config/config.yaml
  28. 2 0
      docs/modules/ROOT/examples/solutions/container-control-panel/config/containers.json
  29. 32 0
      docs/modules/ROOT/examples/solutions/directory-actions/config.yaml
  30. 28 0
      docs/modules/ROOT/examples/solutions/heating-control-panel/configs/config.yaml
  31. 2 0
      docs/modules/ROOT/examples/solutions/heating-control-panel/configs/heating.yaml
  32. 31 0
      docs/modules/ROOT/examples/solutions/human-in-the-control-loop/config.yaml
  33. 35 0
      docs/modules/ROOT/examples/solutions/primitive-password/password.js
  34. 36 0
      docs/modules/ROOT/examples/solutions/systemd-control-panel/config/config.yaml
  35. 4 0
      docs/modules/ROOT/examples/solutions/systemd-control-panel/config/systemd_units.json
  36. 10 0
      docs/modules/ROOT/examples/solutions/wol/config.yaml
  37. 6 0
      docs/modules/ROOT/examples/solutions/wol/config_docker.yaml
  38. 2 0
      docs/modules/ROOT/images/.gitignore
  39. 163 0
      docs/modules/ROOT/images/SCREENSHOTS.md
  40. BIN
      docs/modules/ROOT/images/action-button-iconify.png
  41. BIN
      docs/modules/ROOT/images/action-confirmation.png
  42. 2 0
      docs/modules/ROOT/images/action_buttons/create_your_first/.gitignore
  43. 2 0
      docs/modules/ROOT/images/action_buttons/create_your_first/Makefile
  44. 12 0
      docs/modules/ROOT/images/action_buttons/create_your_first/config.yaml
  45. BIN
      docs/modules/ROOT/images/action_buttons/create_your_first/hello-world.png
  46. 11 0
      docs/modules/ROOT/images/action_buttons/create_your_first/screenshots.ini
  47. 20 0
      docs/modules/ROOT/images/action_buttons/create_your_first/setup_hello.py
  48. 2 0
      docs/modules/ROOT/images/action_buttons/layout/.gitignore
  49. 2 0
      docs/modules/ROOT/images/action_buttons/layout/Makefile
  50. 37 0
      docs/modules/ROOT/images/action_buttons/layout/config.yaml
  51. BIN
      docs/modules/ROOT/images/action_buttons/layout/layout.png
  52. 11 0
      docs/modules/ROOT/images/action_buttons/layout/screenshots.ini
  53. 83 0
      docs/modules/ROOT/images/action_buttons/layout/setup_layout.py
  54. 2 0
      docs/modules/ROOT/images/action_customization/execution-dialog/.gitignore
  55. 2 0
      docs/modules/ROOT/images/action_customization/execution-dialog/Makefile
  56. 17 0
      docs/modules/ROOT/images/action_customization/execution-dialog/config.yaml
  57. BIN
      docs/modules/ROOT/images/action_customization/execution-dialog/executionDialog.png
  58. 11 0
      docs/modules/ROOT/images/action_customization/execution-dialog/screenshots.ini
  59. 45 0
      docs/modules/ROOT/images/action_customization/execution-dialog/setup_execution_dialog.py
  60. 2 0
      docs/modules/ROOT/images/action_customization/timeout-logs/.gitignore
  61. 2 0
      docs/modules/ROOT/images/action_customization/timeout-logs/Makefile
  62. 11 0
      docs/modules/ROOT/images/action_customization/timeout-logs/config.yaml
  63. 11 0
      docs/modules/ROOT/images/action_customization/timeout-logs/screenshots.ini
  64. 42 0
      docs/modules/ROOT/images/action_customization/timeout-logs/setup_timeout_logs.py
  65. BIN
      docs/modules/ROOT/images/action_customization/timeout-logs/timeoutLogs.png
  66. BIN
      docs/modules/ROOT/images/additionalNavigationLinks.png
  67. BIN
      docs/modules/ROOT/images/arg-datetime.png
  68. BIN
      docs/modules/ROOT/images/args-choices-entities.png
  69. BIN
      docs/modules/ROOT/images/args-choices-exec.png
  70. BIN
      docs/modules/ROOT/images/args-multiline-text.png
  71. 2 0
      docs/modules/ROOT/images/args/input/.gitignore
  72. 2 0
      docs/modules/ROOT/images/args/input/Makefile
  73. BIN
      docs/modules/ROOT/images/args/input/args1.png
  74. BIN
      docs/modules/ROOT/images/args/input/args2.png
  75. BIN
      docs/modules/ROOT/images/args/input/args3.png
  76. 16 0
      docs/modules/ROOT/images/args/input/config.yaml
  77. 21 0
      docs/modules/ROOT/images/args/input/screenshots.ini
  78. 19 0
      docs/modules/ROOT/images/args/input/setup_args1.py
  79. 36 0
      docs/modules/ROOT/images/args/input/setup_args2.py
  80. 69 0
      docs/modules/ROOT/images/args/input/setup_args3.py
  81. 2 0
      docs/modules/ROOT/images/args/suggestions/.gitignore
  82. 2 0
      docs/modules/ROOT/images/args/suggestions/Makefile
  83. BIN
      docs/modules/ROOT/images/args/suggestions/arg-suggestions-chrome.png
  84. BIN
      docs/modules/ROOT/images/args/suggestions/arg-suggestions-firefox.png
  85. 21 0
      docs/modules/ROOT/images/args/suggestions/config.yaml
  86. 16 0
      docs/modules/ROOT/images/args/suggestions/screenshots.ini
  87. 93 0
      docs/modules/ROOT/images/args/suggestions/setup_chrome.py
  88. 77 0
      docs/modules/ROOT/images/args/suggestions/setup_firefox.py
  89. BIN
      docs/modules/ROOT/images/args4.png
  90. BIN
      docs/modules/ROOT/images/authentik_login.png
  91. BIN
      docs/modules/ROOT/images/authentik_login2.png
  92. BIN
      docs/modules/ROOT/images/authentik_login3.png
  93. BIN
      docs/modules/ROOT/images/authentik_new_app.png
  94. BIN
      docs/modules/ROOT/images/authentik_provider_config.png
  95. BIN
      docs/modules/ROOT/images/authentik_provider_secrets.png
  96. BIN
      docs/modules/ROOT/images/authentik_select_oauth2.png
  97. BIN
      docs/modules/ROOT/images/blocked.png
  98. BIN
      docs/modules/ROOT/images/dashboard-display.png
  99. BIN
      docs/modules/ROOT/images/dashboard-heating-control-panel.png
  100. 2 0
      docs/modules/ROOT/images/dashboards/intro/.gitignore

+ 0 - 82
.github/dependabot.yml

@@ -1,82 +0,0 @@
-version: 2
-updates:
-  # npm updates for frontend - targeting "next" branch
-  - package-ecosystem: "npm"
-    directory: "/frontend"
-    schedule:
-      interval: "weekly"
-    target-branch: "next"
-    open-pull-requests-limit: 10
-
-  # npm updates for frontend - targeting "release/2k" branch
-  - package-ecosystem: "npm"
-    directory: "/frontend"
-    schedule:
-      interval: "weekly"
-    target-branch: "release/2k"
-    open-pull-requests-limit: 10
-
-  # npm updates for integration-tests - targeting "next" branch
-  - package-ecosystem: "npm"
-    directory: "/integration-tests"
-    schedule:
-      interval: "weekly"
-    target-branch: "next"
-    open-pull-requests-limit: 10
-
-  # npm updates for integration-tests - targeting "release/2k" branch
-  - package-ecosystem: "npm"
-    directory: "/integration-tests"
-    schedule:
-      interval: "weekly"
-    target-branch: "release/2k"
-    open-pull-requests-limit: 10
-
-  # Go modules updates for service - targeting "next" branch
-  - package-ecosystem: "gomod"
-    directory: "/service"
-    schedule:
-      interval: "weekly"
-    target-branch: "next"
-    open-pull-requests-limit: 10
-
-  # Go modules updates for service - targeting "release/2k" branch
-  - package-ecosystem: "gomod"
-    directory: "/service"
-    schedule:
-      interval: "weekly"
-    target-branch: "release/2k"
-    open-pull-requests-limit: 10
-
-  # Go modules updates for lang - targeting "next" branch
-  - package-ecosystem: "gomod"
-    directory: "/lang"
-    schedule:
-      interval: "weekly"
-    target-branch: "next"
-    open-pull-requests-limit: 10
-
-  # Go modules updates for lang - targeting "release/2k" branch
-  - package-ecosystem: "gomod"
-    directory: "/lang"
-    schedule:
-      interval: "weekly"
-    target-branch: "release/2k"
-    open-pull-requests-limit: 10
-
-  # Docker updates - targeting "next" branch
-  - package-ecosystem: "docker"
-    directory: "/"
-    schedule:
-      interval: "weekly"
-    target-branch: "next"
-    open-pull-requests-limit: 10
-
-  # Docker updates - targeting "release/2k" branch
-  - package-ecosystem: "docker"
-    directory: "/"
-    schedule:
-      interval: "weekly"
-    target-branch: "release/2k"
-    open-pull-requests-limit: 10
-

+ 45 - 10
.github/workflows/build-and-release.yml

@@ -3,6 +3,16 @@ name: "Build & Release pipeline"
 
 
 on:
 on:
   pull_request:
   pull_request:
+    paths:
+      - '.github/workflows/build-and-release.yml'
+      - '.goreleaser.yml'
+      - 'Dockerfile.multiarches'
+      - 'Dockerfile.singlearch'
+      - 'Makefile'
+      - 'frontend/**'
+      - 'integration-tests/**'
+      - 'proto/**'
+      - 'service/**'
   workflow_dispatch:
   workflow_dispatch:
   push:
   push:
     tags:
     tags:
@@ -11,31 +21,49 @@ on:
       - main
       - main
       - next
       - next
       - beta
       - beta
+    paths:
+      - '.github/workflows/build-and-release.yml'
+      - '.goreleaser.yml'
+      - 'Dockerfile.multiarches'
+      - 'Dockerfile.singlearch'
+      - 'Makefile'
+      - 'frontend/**'
+      - 'integration-tests/**'
+      - 'proto/**'
+      - 'service/**'
 
 
 jobs:
 jobs:
   build:
   build:
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
       - name: Checkout
       - name: Checkout
-        uses: actions/checkout@v4
+        uses: actions/checkout@v6
         with:
         with:
           fetch-depth: 0
           fetch-depth: 0
 
 
       - name: Set up QEMU
       - name: Set up QEMU
         id: qemu
         id: qemu
-        uses: docker/setup-qemu-action@v3
+        uses: docker/setup-qemu-action@v4
         with:
         with:
           image: tonistiigi/binfmt:latest
           image: tonistiigi/binfmt:latest
           platforms: arm64,arm
           platforms: arm64,arm
 
 
-      - name: Setup node
-        uses: actions/setup-node@v4
+      - name: Setup node (npm cache)
+        if: github.event_name != 'pull_request'
+        uses: actions/setup-node@v6.4.0
         with:
         with:
+          node-version: '22'
           cache: 'npm'
           cache: 'npm'
           cache-dependency-path: frontend/package-lock.json
           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
       - name: Setup Go
-        uses: actions/setup-go@v5
+        uses: actions/setup-go@v6
         with:
         with:
           go-version-file: 'service/go.mod'
           go-version-file: 'service/go.mod'
           cache: true
           cache: true
@@ -45,13 +73,15 @@ jobs:
         run: go version
         run: go version
 
 
       - name: Login to Docker Hub
       - name: Login to Docker Hub
-        uses: docker/login-action@v3
+        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
+        uses: docker/login-action@v4
         with:
         with:
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           username: ${{ secrets.DOCKERHUB_USERNAME }}
           password: ${{ secrets.DOCKERHUB_KEY }}
           password: ${{ secrets.DOCKERHUB_KEY }}
 
 
       - name: Login to ghcr
       - name: Login to ghcr
-        uses: docker/login-action@v3
+        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
+        uses: docker/login-action@v4
         with:
         with:
           registry: ghcr.io
           registry: ghcr.io
           username: ${{ github.actor }}
           username: ${{ github.actor }}
@@ -74,7 +104,7 @@ jobs:
         run: cd integration-tests && make -w
         run: cd integration-tests && make -w
 
 
       - name: Archive integration tests
       - name: Archive integration tests
-        uses: actions/upload-artifact@v4.3.1
+        uses: actions/upload-artifact@v7
         if: always()
         if: always()
         with:
         with:
           name: "OliveTin-integration-tests-${{ env.DATE }}-${{ github.sha }}"
           name: "OliveTin-integration-tests-${{ env.DATE }}-${{ github.sha }}"
@@ -83,12 +113,17 @@ jobs:
             !integration-tests/node_modules
             !integration-tests/node_modules
 
 
       - name: Install goreleaser
       - name: Install goreleaser
+        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
         uses: goreleaser/goreleaser-action@v6
         uses: goreleaser/goreleaser-action@v6
         with:
         with:
           install-only: true
           install-only: true
 
 
+      - name: Set up Docker Buildx
+        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
+        uses: docker/setup-buildx-action@v3
+
       - name: release
       - name: release
-        if: github.ref_type != 'tag'
+        if: github.ref_type != 'tag' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false)
         uses: cycjimmy/semantic-release-action@v4
         uses: cycjimmy/semantic-release-action@v4
         with:
         with:
           extra_plugins: |
           extra_plugins: |
@@ -100,8 +135,8 @@ jobs:
           GH_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
           GH_TOKEN: ${{ secrets.CONTAINER_TOKEN }}
 
 
       - name: Archive binaries
       - name: Archive binaries
+        if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false
         uses: actions/upload-artifact@v4.3.1
         uses: actions/upload-artifact@v4.3.1
         with:
         with:
           name: "OliveTin-snapshot-${{ env.DATE }}-${{ github.sha }}"
           name: "OliveTin-snapshot-${{ env.DATE }}-${{ github.sha }}"
           path: dist/OliveTin*.*
           path: dist/OliveTin*.*
-

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

@@ -15,13 +15,19 @@ name: "CodeQL"
 on:
 on:
   push:
   push:
     paths:
     paths:
-      - 'cmd/**'
-      - 'internal/**'
-      - 'webui.dev/**'
+      - '.github/workflows/codeql-analysis.yml'
+      - 'frontend/**'
       - 'integration-tests/**'
       - 'integration-tests/**'
-      - 'OliveTin.proto'
+      - 'proto/**'
+      - 'service/**'
     branches: [main]
     branches: [main]
   pull_request:
   pull_request:
+    paths:
+      - '.github/workflows/codeql-analysis.yml'
+      - 'frontend/**'
+      - 'integration-tests/**'
+      - 'proto/**'
+      - 'service/**'
     branches: [main]
     branches: [main]
   schedule:
   schedule:
     - cron: '25 10 * * 5'
     - cron: '25 10 * * 5'
@@ -51,6 +57,12 @@ jobs:
           cache: true
           cache: true
           cache-dependency-path: 'service/go.mod'
           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.
       # Initializes the CodeQL tools for scanning.
       - name: Initialize CodeQL
       - name: Initialize CodeQL
         uses: github/codeql-action/init@v3
         uses: github/codeql-action/init@v3

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

@@ -4,11 +4,11 @@ name: "Codestyle checks"
 on:
 on:
   push:
   push:
     paths:
     paths:
-      - 'cmd/**'
-      - 'internal/**'
-      - 'webui.dev/**'
+      - '.github/workflows/codestyle.yml'
+      - 'frontend/**'
       - 'integration-tests/**'
       - 'integration-tests/**'
-      - 'OliveTin.proto'
+      - 'proto/**'
+      - 'service/**'
 
 
 
 
 jobs:
 jobs:
@@ -31,5 +31,10 @@ jobs:
       - name: service
       - name: service
         run: make -wC service codestyle
         run: make -wC service codestyle
 
 
+      - name: Setup Node
+        uses: actions/setup-node@v4
+        with:
+          node-version: '22'
+
       - name: frontend
       - name: frontend
         run: make -wC frontend codestyle
         run: make -wC frontend codestyle

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

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

+ 44 - 0
.github/workflows/docs-antora.yml

@@ -0,0 +1,44 @@
+name: Antora docs
+on:
+  push:
+    paths:
+      - 'docs/**'
+      - 'local-antora-playbook.yml'
+      - 'local-antora-playbook-ci.yml'
+      - '.github/workflows/docs-antora.yml'
+  pull_request:
+    paths:
+      - 'docs/**'
+      - 'local-antora-playbook.yml'
+      - 'local-antora-playbook-ci.yml'
+      - '.github/workflows/docs-antora.yml'
+
+jobs:
+  antora:
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0
+
+      - name: Install Node.js
+        uses: actions/setup-node@v4
+        with:
+          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

+ 6 - 1
.gitignore

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

+ 3 - 1
.goreleaser.yml

@@ -76,8 +76,9 @@ archives:
       - README.md
       - README.md
       - src: Dockerfile.singlearch
       - src: Dockerfile.singlearch
         dst: Dockerfile
         dst: Dockerfile
+      - examples/backupScript.sh
       - webui
       - webui
-      - ./var/
+      - var
     name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}{{ .Arm }}"
     name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}{{ .Arm }}"
     wrap_in_directory: true
     wrap_in_directory: true
     format_overrides:
     format_overrides:
@@ -103,6 +104,7 @@ dockers_v2:
       - var/entities/
       - var/entities/
       - config.yaml
       - config.yaml
       - var/helper-actions/
       - var/helper-actions/
+      - examples/backupScript.sh
     labels:
     labels:
       org.opencontainers.image.revision: "{{ .FullCommit }}"
       org.opencontainers.image.revision: "{{ .FullCommit }}"
       org.opencontainers.image.version: "{{ .Tag }}"
       org.opencontainers.image.version: "{{ .Tag }}"

+ 29 - 2
.pre-commit-config.yaml

@@ -9,6 +9,19 @@ repos:
       - id: end-of-file-fixer
       - id: end-of-file-fixer
       - id: check-yaml
       - id: check-yaml
       - id: check-added-large-files
       - id: check-added-large-files
+      - id: check-merge-conflict
+      - id: detect-private-key
+      - id: mixed-line-ending
+        args: ['--fix', 'lf']
+      - id: check-json
+        exclude: |
+          (?x)^(
+            service/internal/entities/testdata/.*\.json|
+            integration-tests/tests/.*/entities/.*\.json|
+            var/entities/.*\.json
+          )$
+      - id: check-case-conflict
+      - id: detect-aws-credentials
 
 
   # Alternative semantic commit checker
   # Alternative semantic commit checker
   - repo: https://github.com/compilerla/conventional-pre-commit
   - repo: https://github.com/compilerla/conventional-pre-commit
@@ -34,9 +47,23 @@ repos:
         pass_filenames: false
         pass_filenames: false
         always_run: true
         always_run: true
 
 
+      - id: service-unittests
+        name: service-unittests
+        entry: make service-unittests
+        language: system
+        pass_filenames: false
+        always_run: true
+
+      - id: service-build
+        name: service-build
+        entry: make service
+        language: system
+        pass_filenames: false
+        always_run: true
+
       - id: it
       - id: it
-        name: it
-        entry: make service-codestyle frontend-codestyle
+        name: integration-tests
+        entry: make it
         language: system
         language: system
         pass_filenames: false
         pass_filenames: false
         always_run: true
         always_run: true

+ 14 - 3
AGENTS.md

@@ -13,12 +13,15 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
 - **Frontend (Vue 3)**: `frontend/` (served by the service)
 - **Frontend (Vue 3)**: `frontend/` (served by the service)
 - **Integration tests**: `integration-tests/`
 - **Integration tests**: `integration-tests/`
 - **Protos/Generated**: `proto/`, `service/gen/...`
 - **Protos/Generated**: `proto/`, `service/gen/...`
+- **Specs**: `specs/` — Markdown specs that define how code should behave in human-readable form. When changing behavior in a spec-covered area, keep implementation and tests aligned with the spec; do not reference code or symbols in specs (English only).
 
 
 ### How to Run
 ### How to Run
 - Run the server (dev):
 - Run the server (dev):
   - From repo root: `go run ./service`
   - From repo root: `go run ./service`
 - Unit tests (Go):
 - Unit tests (Go):
   - From repo root: `cd service && make unittests`
   - From repo root: `cd service && make unittests`
+- Code style (after editing code in `service/`):
+  - From repo root: `cd service && make codestyle`
 - Integration tests (Mocha + Selenium):
 - Integration tests (Mocha + Selenium):
   - Single test: `cd integration-tests && npx --yes mocha test/general.mjs`
   - Single test: `cd integration-tests && npx --yes mocha test/general.mjs`
   - All tests: `cd integration-tests && npx --yes mocha`
   - All tests: `cd integration-tests && npx --yes mocha`
@@ -41,6 +44,7 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
 - Do not swallow errors; propagate or log meaningfully.
 - Do not swallow errors; propagate or log meaningfully.
 - Match existing formatting; avoid unrelated reformatting.
 - Match existing formatting; avoid unrelated reformatting.
 - Be safe around nils in executor steps (e.g., guard `req.Binding` and `req.Binding.Action`).
 - Be safe around nils in executor steps (e.g., guard `req.Binding` and `req.Binding.Action`).
+- Cyclomatic complexity over 4 is not permitted.
 
 
 ### API and Execution Flow (High-level)
 ### API and Execution Flow (High-level)
 1. Client calls Connect RPC (e.g., `Init`, `GetDashboard`, `StartAction`).
 1. Client calls Connect RPC (e.g., `Init`, `GetDashboard`, `StartAction`).
@@ -59,11 +63,18 @@ If you are looking for OliveTin's AI policy, you can find it in `AI.md`.
 ### Contributing Checklist
 ### Contributing Checklist
 - Review the contributing guidelines at `CONTRIBUTING.adoc`.
 - Review the contributing guidelines at `CONTRIBUTING.adoc`.
 - Review the AI guidance in `AI.md`.
 - Review the AI guidance in `AI.md`.
-- Review the pull request template at `.github/PULL_REQUEST_TEMPLATE.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
 ### Troubleshooting
 - API tests failing with content-type errors: ensure Connect handler is served under `/api/` and the client targets that base URL.
 - 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.
 - Executor panics: check for nil `Binding/Action` and add guards in step functions.
 - Integration timeouts: wait for `loaded-dashboard` and use selectors matching the Vue UI.
 - Integration timeouts: wait for `loaded-dashboard` and use selectors matching the Vue UI.
-
-

+ 1 - 0
CONTRIBUTING.adoc

@@ -58,6 +58,7 @@ make
 The project layout is reasonably straightforward;
 The project layout is reasonably straightforward;
 
 
 * See the `Makefile` for common targets. This project was originally created on top of Fedora, but it should be usable on Debian/your faveourite distro with minor changes (if any).
 * See the `Makefile` for common targets. This project was originally created on top of Fedora, but it should be usable on Debian/your faveourite distro with minor changes (if any).
+* End-user documentation (AsciiDoc for link:https://docs.olivetin.app[docs.olivetin.app]) lives in `docs/` as an Antora component; the published site is built from the separate link:https://github.com/OliveTin/docs.olivetin.app[docs.olivetin.app] repository.
 * The API is defined in protobuf+Connect RPC - you will need to `make proto`.
 * The API is defined in protobuf+Connect RPC - you will need to `make proto`.
 * The Go daemon is built from the `cmd` and `internal` directories mostly.
 * The Go daemon is built from the `cmd` and `internal` directories mostly.
 * The webui is just a single page application with a bit of Javascript in the `webui` directory. This can happily be hosted on another webserver.
 * The webui is just a single page application with a bit of Javascript in the `webui` directory. This can happily be hosted on another webserver.

+ 5 - 3
Dockerfile.multiarches

@@ -1,17 +1,17 @@
 # Multi-arch Dockerfile for GoReleaser (dockers_v2).
 # Multi-arch Dockerfile for GoReleaser (dockers_v2).
-# Base image :43 is used without arch suffix so the registry can supply the right
+# Base image :44 is used without arch suffix so the registry can supply the right
 # platform (manifest list). TARGETPLATFORM is set by BuildKit for COPY.
 # platform (manifest list). TARGETPLATFORM is set by BuildKit for COPY.
 # For custom/local single-arch builds, use Dockerfile.singlearch instead.
 # For custom/local single-arch builds, use Dockerfile.singlearch instead.
 
 
 ARG TARGETPLATFORM
 ARG TARGETPLATFORM
 
 
-FROM registry.fedoraproject.org/fedora-minimal:43 AS olivetin-tmputils
+FROM registry.fedoraproject.org/fedora-minimal:44 AS olivetin-tmputils
 
 
 RUN microdnf -y install dnf-plugins-core && \
 RUN microdnf -y install dnf-plugins-core && \
 	dnf-3 config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo && \
 	dnf-3 config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo && \
 	microdnf install -y docker-ce-cli docker-compose-plugin && microdnf clean all
 	microdnf install -y docker-ce-cli docker-compose-plugin && microdnf clean all
 
 
-FROM registry.fedoraproject.org/fedora-minimal:43
+FROM registry.fedoraproject.org/fedora-minimal:44
 
 
 LABEL org.opencontainers.image.source https://github.com/OliveTin/OliveTin
 LABEL org.opencontainers.image.source https://github.com/OliveTin/OliveTin
 LABEL org.opencontainers.image.title OliveTin
 LABEL org.opencontainers.image.title OliveTin
@@ -42,6 +42,8 @@ EXPOSE 1337/tcp
 
 
 COPY config.yaml /config
 COPY config.yaml /config
 COPY var/entities/* /config/entities/
 COPY var/entities/* /config/entities/
+COPY examples/backupScript.sh /opt/backupScript.sh
+RUN chmod 755 /opt/backupScript.sh
 VOLUME /config
 VOLUME /config
 
 
 ARG TARGETPLATFORM
 ARG TARGETPLATFORM

+ 4 - 2
Dockerfile.singlearch

@@ -1,10 +1,10 @@
-FROM --platform=linux/amd64 registry.fedoraproject.org/fedora-minimal:43-x86_64 AS olivetin-tmputils
+FROM --platform=linux/amd64 registry.fedoraproject.org/fedora-minimal:44-x86_64 AS olivetin-tmputils
 
 
 RUN microdnf -y install dnf-plugins-core && \
 RUN microdnf -y install dnf-plugins-core && \
 	dnf-3 config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo && \
 	dnf-3 config-manager --add-repo https://download.docker.com/linux/fedora/docker-ce.repo && \
 	microdnf install -y docker-ce-cli docker-compose-plugin && microdnf clean all
 	microdnf install -y docker-ce-cli docker-compose-plugin && microdnf clean all
 
 
-FROM --platform=linux/amd64 registry.fedoraproject.org/fedora-minimal:43-x86_64
+FROM --platform=linux/amd64 registry.fedoraproject.org/fedora-minimal:44-x86_64
 
 
 LABEL org.opencontainers.image.source https://github.com/OliveTin/OliveTin
 LABEL org.opencontainers.image.source https://github.com/OliveTin/OliveTin
 LABEL org.opencontainers.image.title OliveTin
 LABEL org.opencontainers.image.title OliveTin
@@ -35,6 +35,8 @@ EXPOSE 1337/tcp
 
 
 COPY config.yaml /config
 COPY config.yaml /config
 COPY var/entities/* /config/entities/
 COPY var/entities/* /config/entities/
+COPY examples/backupScript.sh /opt/backupScript.sh
+RUN chmod 755 /opt/backupScript.sh
 VOLUME /config
 VOLUME /config
 
 
 COPY OliveTin /usr/bin/OliveTin
 COPY OliveTin /usr/bin/OliveTin

+ 3 - 0
README.md

@@ -10,6 +10,7 @@
 [![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/5050/badge)](https://bestpractices.coreinfrastructure.org/projects/5050)
 [![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/5050/badge)](https://bestpractices.coreinfrastructure.org/projects/5050)
 
 
 [![Go Report Card](https://goreportcard.com/badge/github.com/Olivetin/OliveTin)](https://goreportcard.com/report/github.com/OliveTin/OliveTin)
 [![Go Report Card](https://goreportcard.com/badge/github.com/Olivetin/OliveTin)](https://goreportcard.com/report/github.com/OliveTin/OliveTin)
+[![AI Autonomy Level](https://img.shields.io/badge/AI%20Autonomy-Level%201%20of%205%20(assistance--only)-blue)](https://blog.jread.com/posts/ai-levels-of-autonomy-in-software-engineering/)
 
 
 [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>
@@ -19,6 +20,8 @@
 
 
 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.
 
 
+The AsciiDoc sources for that site live in this repository under [`docs/`](docs/) (Antora component). The [docs.olivetin.app](https://github.com/OliveTin/docs.olivetin.app) repository contains the Antora playbook, theme supplemental files, and the workflow that publishes GitHub Pages.
+
 ## Use cases
 ## Use cases
 
 
 **Safely** give access to commands, for less technical people;
 **Safely** give access to commands, for less technical people;

+ 50 - 3
SECURITY.md

@@ -2,12 +2,59 @@
 
 
 ## Supported Versions
 ## Supported Versions
 
 
-Currently, only the `main` branch is "supported".
+The following branches are currently being supported with security updates:
 
 
 | Version | Supported          |
 | Version | Supported          |
 | ------- | ------------------ |
 | ------- | ------------------ |
-| `main`  | :white_check_mark: |
+| `main` (3k release branch)  | :white_check_mark: - advisories will be published when patched in this branch |
+| `release/2k` (2k release branch) | :white_check_mark: - receives security updates, but much slower |
+
+To understand more about 2k vs 3k, see the following docs; https://docs.olivetin.app/upgrade/2k3k.html
+
+## OliveTin *is* a remote code execution (RCE) "tool"
+
+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.
+
+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.
 
 
 ## Reporting a Vulnerability
 ## Reporting a Vulnerability
 
 
-Please email `contact@jread.com` for responsible disclosure. Accepted issues will be made public once patched, and you will be given credit.
+Please use responsible disclosure practices when reporting a vulnerability. **You will receive full credit for your discovery**, and we will work with you to ensure that the issue is resolved as quickly as **possible**. Please note that only James Read has access to security issues at the moment, so please be patient and understanding if you do not receive an immediate response.
+
+* **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.
+
+The following notes might be helpful when reporting a vulnerability:
+
+* OliveTin does not offer a bug bounty program.
+* GitHub usernames are how we you will be credited for discoveries reported via GitHub, if using emails we'll ask for your preferred name/handle to credit you with.
+* CVEs will be requested via GitHub Security Advisories when appropriate, but we do not guarantee that all vulnerabilities will receive CVEs, as this is determined on a case-by-case basis.
+
+## Disclosure of how vulnerabilities were found
+
+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.

+ 90 - 45
config.yaml

@@ -6,14 +6,39 @@
 listenAddressSingleHTTPFrontend: 0.0.0.0:1337
 listenAddressSingleHTTPFrontend: 0.0.0.0:1337
 
 
 # Choose from INFO (default), WARN and DEBUG
 # 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"
 logLevel: "INFO"
 
 
 # Actions are commands that are executed by OliveTin, and normally show up as
 # Actions are commands that are executed by OliveTin, and normally show up as
 # buttons on the WebUI.
 # 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:
 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/ ).
+  #
   # This is the most simple action, it just runs the command and flashes the
   # This is the most simple action, it just runs the command and flashes the
   # button to indicate status.
   # button to indicate status.
   #
   #
@@ -22,47 +47,40 @@ actions:
   - title: Ping the Internet
   - title: Ping the Internet
     shell: ping -c 3 1.1.1.1
     shell: ping -c 3 1.1.1.1
     icon: ping
     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
-
-  # 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
-
-  # This uses `popupOnStart: execution-button` to display a mini button that
-  # links to the logs.
-  #
   # You can also rate-limit actions too.
   # 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:
     maxRate:
       - limit: 3
       - limit: 3
         duration: 1m
         duration: 1m
 
 
   # You are not limited to operating system commands, and of course you can run
   # 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
   - title: Run backup script
     shell: /opt/backupScript.sh
     shell: /opt/backupScript.sh
     shellAfterCompleted: "apprise -t 'Notification: Backup script completed' -b 'The backup script completed with code {{ exitCode}}. The log is: \n {{ output }} '"
     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
     timeout: 10
     icon: backup
     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
   # 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.
   # `arguments` - this presents a popup dialog and asks for argument values.
@@ -73,7 +91,12 @@ actions:
     shell: ping {{ host }} -c {{ count }}
     shell: ping {{ host }} -c {{ count }}
     icon: ping
     icon: ping
     timeout: 100
     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:
+      - matchHeaders:
+          X-OliveTin-Demo: ping-host
     arguments:
     arguments:
       - name: host
       - name: host
         title: Host
         title: Host
@@ -95,7 +118,7 @@ actions:
   # Docs: https://docs.olivetin.app/solutions/container-control-panel/index.html
   # Docs: https://docs.olivetin.app/solutions/container-control-panel/index.html
   - title: Restart Docker Container
   - title: Restart Docker Container
     icon: restart
     icon: restart
-    shell: docker restart {{ .CurrentEntity }}
+    shell: docker restart {{ container }}
     arguments:
     arguments:
       - name: container
       - name: container
         title: Container name
         title: Container name
@@ -110,7 +133,8 @@ actions:
   # Docs: https://docs.olivetin.app/args/input_confirmation.html
   # Docs: https://docs.olivetin.app/args/input_confirmation.html
   - title: Delete old backups
   - title: Delete old backups
     icon: ashtonished
     icon: ashtonished
-    shell: rm -rf /opt/oldBackups/
+    justification: true
+    shell: rm -rf /opt/oliveTinOldBackups/ && sleep 5
     arguments:
     arguments:
       - type: html
       - type: html
         title: Description
         title: Description
@@ -124,7 +148,7 @@ actions:
   #
   #
   # Docs: https://docs.olivetin.app/reference/reference_themes_for_users.html
   # Docs: https://docs.olivetin.app/reference/reference_themes_for_users.html
   - title: Get OliveTin Theme
   - title: Get OliveTin Theme
-    exec: 
+    exec:
       - "olivetin-get-theme"
       - "olivetin-get-theme"
       - "{{ themeGitRepo }}"
       - "{{ themeGitRepo }}"
       - "{{ themeFolderName }}"
       - "{{ themeFolderName }}"
@@ -148,7 +172,11 @@ actions:
   - title: "Setup easy SSH"
   - title: "Setup easy SSH"
     icon: ssh
     icon: ssh
     shell: olivetin-setup-easy-ssh
     shell: olivetin-setup-easy-ssh
-    popupOnStart: execution-dialog
+    onclick: execution-dialog
+    # Second webhook example: POST /webhooks?demo=setup-ssh
+    execOnWebhook:
+      - matchQuery:
+          demo: setup-ssh
 
 
   # Here's how to use SSH with the "easy" config, to restart a service on
   # Here's how to use SSH with the "easy" config, to restart a service on
   # another server.
   # another server.
@@ -161,13 +189,6 @@ actions:
     timeout: 1
     timeout: 1
     shell: ssh -F /config/ssh/easy.cfg root@server1 'service httpd restart'
     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
   # There are several built-in shortcuts for the `icon` option, but you
   # can also just specify any HTML, this includes any unicode character,
   # can also just specify any HTML, this includes any unicode character,
   # or a <img = "..." /> link to a custom icon.
   # or a <img = "..." /> link to a custom icon.
@@ -215,6 +236,10 @@ actions:
   - title: Ping All Servers
   - title: Ping All Servers
     shell: "echo 'Ping all servers'"
     shell: "echo 'Ping all servers'"
     icon: ping
     icon: ping
+    # https://docs.olivetin.app/action_execution/onfilecreated.html
+    # mkdir -p /tmp/olivetin-demo-file-created
+    execOnFileCreatedInDir:
+      - /tmp/olivetin-demo-file-created
 
 
   - title: Start {{ .CurrentEntity.Names }}
   - title: Start {{ .CurrentEntity.Names }}
     icon: box
     icon: box
@@ -228,6 +253,15 @@ actions:
     entity: container
     entity: container
     triggers: ["Update container entity file"]
     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
   # 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
   # background helpers that execute only on startup or a cron, for updating
   # entity files.
   # entity files.
@@ -269,6 +303,17 @@ entities:
   - file: entities/containers.json
   - file: entities/containers.json
     name: container
     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
 # Dashboards are a way of taking actions from the default "actions" view, and
 # organizing them into groups - either into folders, or fieldsets.
 # organizing them into groups - either into folders, or fieldsets.
 #
 #
@@ -341,7 +386,7 @@ dashboards:
 
 
 # Security - Authentication
 # 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.
 # If set to "true", then users will have to login to do anything.
 authRequireGuestsToLogin: false
 authRequireGuestsToLogin: false
 
 
@@ -350,7 +395,7 @@ authRequireGuestsToLogin: false
 # and JWT authentication which are documented separately.
 # and JWT authentication which are documented separately.
 #
 #
 # Docs: https://docs.olivetin.app/security/local.html
 # Docs: https://docs.olivetin.app/security/local.html
-# 
+#
 # How to get a hashed password:
 # How to get a hashed password:
 # Docs: https://docs.olivetin.app/security/local.html#_get_a_argon2id_hashed_password
 # Docs: https://docs.olivetin.app/security/local.html#_get_a_argon2id_hashed_password
 authLocalUsers:
 authLocalUsers:

+ 15 - 0
docs/antora.yml

@@ -0,0 +1,15 @@
+---
+name: ROOT
+title: OliveTin
+version: ''
+display_version: 'Version 3k'
+start_page: index.adoc
+asciidoc:
+  attributes:
+    source-language: asciidoc@
+    table-caption: false
+    toclevels: 2
+nav:
+- modules/ROOT/nav.adoc
+
+

+ 40 - 0
docs/modules/ROOT/check_chevron_links.py

@@ -0,0 +1,40 @@
+#!/usr/bin/env python3
+
+import glob
+import re
+
+nav_file = open('nav.adoc', 'r')
+nav_string = nav_file.read()
+
+adoc_files = glob.glob('pages/**/*.adoc', recursive=True)
+
+filelist = dict()
+
+for file in adoc_files:
+    with open(file, 'r') as handle:
+        content = handle.read()
+
+        matches = re.findall(r'<<(.*?),?([\w\- ]+)>>', content)
+
+        for match in matches:
+            m = match
+
+            if match[0] == "":
+                m = match[1]
+            else:
+                m = match[0]
+
+            if content.count("#" + m) != 1:
+                if file not in filelist:
+                    filelist[file] = list()
+
+                filelist[file].append(m)
+
+
+print("Files:", len(filelist))
+
+for file in filelist.keys():
+    print(file)
+
+    for match in filelist[file]:
+        print("\t", match)

+ 24 - 0
docs/modules/ROOT/check_no_h1.py

@@ -0,0 +1,24 @@
+#!/usr/bin/env python3
+
+import glob
+import re
+
+adoc_files = glob.glob('pages/**/*.adoc', recursive=True)
+
+filelist = list()
+
+for file in adoc_files:
+    with open(file, 'r') as handle:
+        content = handle.read()
+
+        matches = re.findall('^= ', content, re.MULTILINE)
+
+        if len(matches) == 0:
+            filelist.append(file)
+
+
+print("Files:", len(filelist))
+
+for file in filelist:
+    print(file)
+

+ 25 - 0
docs/modules/ROOT/check_unnavigable.py

@@ -0,0 +1,25 @@
+#!/usr/bin/env python3
+
+# find .adoc files that are not navigable from the nav.adoc file
+
+import glob
+
+nav_file = open('nav.adoc', 'r')
+nav_string = nav_file.read()
+
+adoc_files = glob.glob('pages/**/*.adoc', recursive=True)
+
+unnavigable_files = []
+
+for file in adoc_files:
+    filename = file.replace("pages/", "")
+
+    if filename not in nav_string:
+        unnavigable_files.append(filename)
+
+
+unnavigable_files = sorted(unnavigable_files)
+
+print("Unnavigable files:", len(unnavigable_files))
+for file in unnavigable_files:
+    print(file)

+ 15 - 0
docs/modules/ROOT/examples/action_customization/icons/config.yaml

@@ -0,0 +1,15 @@
+actions:
+  - title: Unicode (emoji) alias icon
+    shell: echo "Hello!"
+    icon: smile
+
+  - title: Unicode (emoji) icon
+    shell: echo "Hello!"
+    icon: "&#128526;"
+
+  - title: Iconify Icon
+    icon: <iconify-icon icon="ant-design:bug-filled"></iconify-icon>
+
+  - title: HTML Image (jpg/png/gif/etc) icon
+    shell: echo "Hello!"
+    icon: '<img src = "custom-webui/icons/mrgreen.gif" style = "width: 1em;" />'

+ 9 - 0
docs/modules/ROOT/examples/k8s_configmap.yml

@@ -0,0 +1,9 @@
+apiVersion: v1
+kind: ConfigMap
+metadata:
+  name: olivetin-config
+data:
+  config.yaml: |
+    actions:
+      - title: "Hello world!"
+        shell: echo 'Hello World!'

+ 37 - 0
docs/modules/ROOT/examples/k8s_deployment.yml

@@ -0,0 +1,37 @@
+apiVersion: apps/v1
+kind: Deployment
+metadata: 
+    name: olivetin
+spec: 
+  replicas: 1
+  selector: 
+    matchLabels:
+      app: olivetin
+  template:
+    metadata:
+      labels:
+        app: olivetin
+    spec:
+      containers: 
+        - name: olivetin
+          image: docker.io/jamesread/olivetin:latest
+          ports:
+            - containerPort: 1337
+          volumeMounts:
+            - name: olivetin-config
+              mountPath: "/config"
+              readOnly: true
+
+          livenessProbe:
+            exec:
+              command: 
+                - curl 
+                - localhost:1337
+            initialDelaySeconds: 5
+            periodSeconds: 30
+
+      volumes:
+        - name: olivetin-config
+          configMap: 
+            name: olivetin-config
+

+ 21 - 0
docs/modules/ROOT/examples/k8s_ingress.yml

@@ -0,0 +1,21 @@
+apiVersion: networking.k8s.io/v1
+kind: Ingress
+metadata: 
+  name: olivetin-ingress
+spec:
+  defaultBackend: 
+    service: 
+      name: olivetin
+      port:
+        number: 1337
+  rules:
+    - host: olivetin.apps.ocp.teratan.net
+      http:
+        paths:
+          - path: /
+            pathType: Prefix
+            backend:
+              service:
+                name: olivetin
+                port:
+                  number: 1337

+ 19 - 0
docs/modules/ROOT/examples/reverse-proxies/etc/npm-docker-compose.yml

@@ -0,0 +1,19 @@
+services:
+  app:
+    image: 'jc21/nginx-proxy-manager:latest'
+    restart: unless-stopped
+    ports:
+      - '80:80'
+      - '81:81'
+      - '443:443'
+    volumes:
+      - ./data:/data
+      - ./letsencrypt:/etc/letsencrypt
+  olivetin:
+    container_name: olivetin
+    image: jamesread/olivetin
+    volumes:
+      - ./OliveTin:/config # replace host path or volume as needed
+    ports:
+      - "1337:1337"
+    restart: unless-stopped

+ 25 - 0
docs/modules/ROOT/examples/reverse-proxies/etc/reverse_proxy_nginx_dns.conf

@@ -0,0 +1,25 @@
+server {
+    listen 443 ssl;
+
+    ssl_certificate "/etc/nginx/conf.d/server.crt";
+    ssl_certificate_key "/etc/nginx/conf.d/server.key";
+
+    access_log  /var/log/nginx/ot.access.log  main;
+    error_log /var/log/nginx/ot.error.log notice;
+
+    server_name olivetin.example.com;
+
+    location / {
+        proxy_pass http://localhost:1337/;
+        proxy_redirect http://localhost:1337/ http://localhost/OliveTin/;
+    }
+
+    location /websocket {
+        proxy_set_header Upgrade "websocket";
+        proxy_set_header Connection "upgrade";
+        proxy_pass http://localhost:1337/websocket;
+        proxy_read_timeout 600s;
+        proxy_send_timeout 600s;
+    }
+}
+

+ 48 - 0
docs/modules/ROOT/examples/solutions/container-control-panel/config/config.yaml

@@ -0,0 +1,48 @@
+# This config has two actions which are applied to all "container" entities 
+# found in the entity file.
+#
+# Docs: http://localhost/docs.olivetin.app/docs/entities.html
+actions:
+  - title: Start {{ container.Names }}
+    icon: box
+    shell: docker start {{ container.Names }}
+    entity: container
+    triggers:
+      - Update container entity file
+
+  - title: Stop {{ container.Names }}
+    icon: box
+    shell: docker stop {{ container.Names }}
+    entity: container
+    triggers:
+      - Update container entity file
+
+  # This is a hidden action, that is run on startup, and every 5 minutes, and 
+  # when the above start/stop commands are run (see the `triggers` property).
+
+  - title: Update container entity file
+    shell: 'docker ps -a --format json > /etc/OliveTin/entities/containers.json'
+    hidden: true
+    execOnStartup: true
+    execOnCron: '*/5 * * * *'
+
+# Docs: http://docs.olivetin.app/entities.html
+entities:
+  - file: /etc/OliveTin/entities/containers.json
+    name: container
+
+# The only way to properly use entities, are to use them with a `fieldset` on
+# a dashboard.
+dashboards:
+  # This is the second dashboard.
+  - 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/examples/solutions/container-control-panel/config/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"}

+ 32 - 0
docs/modules/ROOT/examples/solutions/directory-actions/config.yaml

@@ -0,0 +1,32 @@
+actions:
+  - title: check log directory
+    hidden: true
+    shell: |
+      function addDirectory {
+        COUNT=$(ls -l $1 | wc -l)
+        echo "- directory: $1" >> /etc/OliveTin/entities/directories.yaml
+        echo "  count: $COUNT" >> /etc/OliveTin/entities/directories.yaml
+      }
+
+      truncate -s 0 /etc/OliveTin/entities/directories.yaml
+      addDirectory /var/log/
+      addDirectory /home/xconspirisist/logs
+    execOnStartup: true
+    execOnCron: "* * * * *"
+
+  - title: clean {{ log_directory.directory }} ({{log_directory.count }} files)
+    shell: |
+      echo "Removing all files in {{ log_directory.directory }}"
+    entity: log_directory
+
+entities:
+  - name: log_directory
+    file: /etc/OliveTin/entities/directories.yaml
+
+dashboards:
+  - title: Log Actions
+    contents:
+      - entity: log_directory
+        type: fieldset
+        contents:
+          - title: clean {{ log_directory.directory }} ({{log_directory.count }} files)

+ 28 - 0
docs/modules/ROOT/examples/solutions/heating-control-panel/configs/config.yaml

@@ -0,0 +1,28 @@
+logLevel: "INFO"
+
+actions:
+  - title: Turn heating up
+    icon: '&#128316;'
+    shell: /opt/heating.sh up
+
+  - title: Turn heating down
+    icon: '&#128317;'
+    shell: /opt/heating.sh down
+
+entities:
+  - file: /etc/OliveTin/entities/heating.yaml
+    name: heating
+
+dashboards:
+  - title: Heating Control Panel
+    contents:
+      - title: "{{ heater.title }}"
+        entity: heating
+        type: fieldset
+        contents:
+          - type: display
+            title: |
+              <span class = "icon">&#127777;</span> <br />{{ heating.temperature }}
+
+          - title: Turn heating up
+          - title: Turn heating down

+ 2 - 0
docs/modules/ROOT/examples/solutions/heating-control-panel/configs/heating.yaml

@@ -0,0 +1,2 @@
+- title: Main heater
+  temperature: 20 degrees

+ 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

+ 35 - 0
docs/modules/ROOT/examples/solutions/primitive-password/password.js

@@ -0,0 +1,35 @@
+const myPassword = 'sekrit'
+
+const domMain = document.getElementsByTagName('main')[0]
+domMain.style.display = 'none'
+
+const domPassword = document.createElement('input')
+const domLogin = document.createElement('button')
+
+function checkPassword () {
+  if (domPassword.value === myPassword) {
+    domMain.style.display = 'block'
+    domPassword.remove()
+    domLogin.remove()
+  } else {
+    window.alert('Incorrect password. Please try again.')
+  }
+}
+
+function setupPasswordForm () {
+  domPassword.setAttribute('type', 'password')
+  domPassword.addEventListener('keydown', (e) => {
+    if (e.key === 'Enter') {
+      checkPassword()
+    }
+  })
+
+  domLogin.innerText = 'Login'
+  domLogin.onclick = checkPassword
+
+  const domHeader = document.querySelector('header')
+  domHeader.appendChild(domPassword)
+  domHeader.appendChild(domLogin)
+}
+
+document.addEventListener('DOMContentLoaded', setupPasswordForm)

+ 36 - 0
docs/modules/ROOT/examples/solutions/systemd-control-panel/config/config.yaml

@@ -0,0 +1,36 @@
+actions:
+  - title: Stop {{ systemd_unit.unit }}
+    shell: systemctl stop {{ systemd_unit.unit }}
+    icon: <iconify-icon icon="zondicons:hand-stop"></iconify-icon>
+    entity: systemd_unit
+    triggers:
+      - Update services file
+
+  - title: Start {{ systemd_unit.unit }}
+    shell: systemctl start {{ systemd_unit.unit }}
+    icon: <iconify-icon icon="ic:round-directions-run"></iconify-icon>
+    entity: systemd_unit
+    triggers:
+      - Update services file
+
+  - title: Update services file
+    shell: systemctl list-units -a -o json --no-pager | jq -c 'map(select (.unit | contains ("upsilon", "podman", "boot.mount"))) | .[]'  > /etc/OliveTin/entities/systemd_units.json
+    hidden: true
+    execOnStartup: true
+
+entities:
+  - file: /etc/OliveTin/entities/systemd_units.json
+    name: systemd_unit
+
+dashboards:
+  - title: My Services
+    contents:
+      - title: '{{ systemd_unit.description }}'
+        type: fieldset
+        entity: systemd_unit
+        contents:
+          - title: 'Status: {{ systemd_unit.sub }}'
+            type: display
+
+          - title: Start {{ systemd_unit.unit }}
+          - title: Stop {{ systemd_unit.unit }}

+ 4 - 0
docs/modules/ROOT/examples/solutions/systemd-control-panel/config/systemd_units.json

@@ -0,0 +1,4 @@
+{"unit":"boot.mount","load":"loaded","active":"active","sub":"mounted","description":"/boot"}
+{"unit":"podman.service","load":"loaded","active":"inactive","sub":"dead","description":"Podman API Service"}
+{"unit":"upsilon-drone.service","load":"loaded","active":"active","sub":"running","description":"upsilon-drone"}
+{"unit":"podman.socket","load":"loaded","active":"active","sub":"listening","description":"Podman API Socket"}

+ 10 - 0
docs/modules/ROOT/examples/solutions/wol/config.yaml

@@ -0,0 +1,10 @@
+actions:
+  - title: WakeOnLan Server1
+    shell: ether-wake A8:5E:45:E4:FF:2A
+    icon: ping
+
+  - title: Install ether-wake on startup
+    shell: microdnf install -y net-tools
+    hidden: true
+    execOnStartup: true
+    timeout: 120

+ 6 - 0
docs/modules/ROOT/examples/solutions/wol/config_docker.yaml

@@ -0,0 +1,6 @@
+  - title: WakeOnLan Server1
+    # The r0gger/docker-wake-on-lan is a minimal container for WOL
+    # that can be run on the host network.
+    # It is not required to run the OliveTin container on the host network.
+    shell: |
+     docker run --rm --name wake-on-lan --net=host -e MAC='A8:5E:45:E4:FF:2A' r0gger/docker-wake-on-lan

+ 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.

BIN
docs/modules/ROOT/images/action-button-iconify.png


BIN
docs/modules/ROOT/images/action-confirmation.png


+ 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/additionalNavigationLinks.png


BIN
docs/modules/ROOT/images/arg-datetime.png


BIN
docs/modules/ROOT/images/args-choices-entities.png


BIN
docs/modules/ROOT/images/args-choices-exec.png


BIN
docs/modules/ROOT/images/args-multiline-text.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/args4.png


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


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


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


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


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


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


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


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


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


BIN
docs/modules/ROOT/images/dashboard-heating-control-panel.png


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

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

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است