ソースを参照

docs: Major improvements in screenshots/layout

jamesread 2 週間 前
コミット
cb5581e5eb
100 ファイル変更1703 行追加1 行削除
  1. 1 1
      config.yaml
  2. 31 0
      docs/modules/ROOT/examples/solutions/human-in-the-control-loop/config.yaml
  3. 2 0
      docs/modules/ROOT/images/.gitignore
  4. 163 0
      docs/modules/ROOT/images/SCREENSHOTS.md
  5. 2 0
      docs/modules/ROOT/images/action_buttons/create_your_first/.gitignore
  6. 2 0
      docs/modules/ROOT/images/action_buttons/create_your_first/Makefile
  7. 12 0
      docs/modules/ROOT/images/action_buttons/create_your_first/config.yaml
  8. BIN
      docs/modules/ROOT/images/action_buttons/create_your_first/hello-world.png
  9. 11 0
      docs/modules/ROOT/images/action_buttons/create_your_first/screenshots.ini
  10. 20 0
      docs/modules/ROOT/images/action_buttons/create_your_first/setup_hello.py
  11. 2 0
      docs/modules/ROOT/images/action_buttons/layout/.gitignore
  12. 2 0
      docs/modules/ROOT/images/action_buttons/layout/Makefile
  13. 37 0
      docs/modules/ROOT/images/action_buttons/layout/config.yaml
  14. BIN
      docs/modules/ROOT/images/action_buttons/layout/layout.png
  15. 11 0
      docs/modules/ROOT/images/action_buttons/layout/screenshots.ini
  16. 83 0
      docs/modules/ROOT/images/action_buttons/layout/setup_layout.py
  17. 2 0
      docs/modules/ROOT/images/action_customization/execution-dialog/.gitignore
  18. 2 0
      docs/modules/ROOT/images/action_customization/execution-dialog/Makefile
  19. 17 0
      docs/modules/ROOT/images/action_customization/execution-dialog/config.yaml
  20. BIN
      docs/modules/ROOT/images/action_customization/execution-dialog/executionDialog.png
  21. 11 0
      docs/modules/ROOT/images/action_customization/execution-dialog/screenshots.ini
  22. 45 0
      docs/modules/ROOT/images/action_customization/execution-dialog/setup_execution_dialog.py
  23. 2 0
      docs/modules/ROOT/images/action_customization/timeout-logs/.gitignore
  24. 2 0
      docs/modules/ROOT/images/action_customization/timeout-logs/Makefile
  25. 11 0
      docs/modules/ROOT/images/action_customization/timeout-logs/config.yaml
  26. 11 0
      docs/modules/ROOT/images/action_customization/timeout-logs/screenshots.ini
  27. 42 0
      docs/modules/ROOT/images/action_customization/timeout-logs/setup_timeout_logs.py
  28. BIN
      docs/modules/ROOT/images/action_customization/timeout-logs/timeoutLogs.png
  29. BIN
      docs/modules/ROOT/images/arg-suggestions-chrome.png
  30. BIN
      docs/modules/ROOT/images/arg-suggestions-firefox.png
  31. 2 0
      docs/modules/ROOT/images/args/input/.gitignore
  32. 2 0
      docs/modules/ROOT/images/args/input/Makefile
  33. BIN
      docs/modules/ROOT/images/args/input/args1.png
  34. BIN
      docs/modules/ROOT/images/args/input/args2.png
  35. BIN
      docs/modules/ROOT/images/args/input/args3.png
  36. 16 0
      docs/modules/ROOT/images/args/input/config.yaml
  37. 21 0
      docs/modules/ROOT/images/args/input/screenshots.ini
  38. 19 0
      docs/modules/ROOT/images/args/input/setup_args1.py
  39. 36 0
      docs/modules/ROOT/images/args/input/setup_args2.py
  40. 69 0
      docs/modules/ROOT/images/args/input/setup_args3.py
  41. 2 0
      docs/modules/ROOT/images/args/suggestions/.gitignore
  42. 2 0
      docs/modules/ROOT/images/args/suggestions/Makefile
  43. BIN
      docs/modules/ROOT/images/args/suggestions/arg-suggestions-chrome.png
  44. BIN
      docs/modules/ROOT/images/args/suggestions/arg-suggestions-firefox.png
  45. 21 0
      docs/modules/ROOT/images/args/suggestions/config.yaml
  46. 16 0
      docs/modules/ROOT/images/args/suggestions/screenshots.ini
  47. 93 0
      docs/modules/ROOT/images/args/suggestions/setup_chrome.py
  48. 77 0
      docs/modules/ROOT/images/args/suggestions/setup_firefox.py
  49. BIN
      docs/modules/ROOT/images/args1.png
  50. BIN
      docs/modules/ROOT/images/args2.png
  51. BIN
      docs/modules/ROOT/images/args3.png
  52. BIN
      docs/modules/ROOT/images/dashboard.png
  53. 2 0
      docs/modules/ROOT/images/dashboards/intro/.gitignore
  54. 2 0
      docs/modules/ROOT/images/dashboards/intro/Makefile
  55. 53 0
      docs/modules/ROOT/images/dashboards/intro/config.yaml
  56. BIN
      docs/modules/ROOT/images/dashboards/intro/preview.png
  57. 11 0
      docs/modules/ROOT/images/dashboards/intro/screenshots.ini
  58. 9 0
      docs/modules/ROOT/images/dashboards/intro/servers.yaml
  59. 42 0
      docs/modules/ROOT/images/dashboards/intro/setup_preview.py
  60. BIN
      docs/modules/ROOT/images/executionDialog.png
  61. BIN
      docs/modules/ROOT/images/hello-world.png
  62. 2 0
      docs/modules/ROOT/images/logs/views/.gitignore
  63. 2 0
      docs/modules/ROOT/images/logs/views/Makefile
  64. 31 0
      docs/modules/ROOT/images/logs/views/config.yaml
  65. BIN
      docs/modules/ROOT/images/logs/views/logsCalendar.png
  66. BIN
      docs/modules/ROOT/images/logs/views/logsList.png
  67. BIN
      docs/modules/ROOT/images/logs/views/logsQueue.png
  68. 24 0
      docs/modules/ROOT/images/logs/views/screenshots.ini
  69. 78 0
      docs/modules/ROOT/images/logs/views/setup_logs_calendar.py
  70. 73 0
      docs/modules/ROOT/images/logs/views/setup_logs_list.py
  71. 69 0
      docs/modules/ROOT/images/logs/views/setup_logs_queue.py
  72. 49 0
      docs/modules/ROOT/images/screenshots.mk
  73. BIN
      docs/modules/ROOT/images/solution-k8s-hosted.png
  74. BIN
      docs/modules/ROOT/images/solution-systemd-control-panel.png
  75. 2 0
      docs/modules/ROOT/images/solutions/container-control-panel/.gitignore
  76. 2 0
      docs/modules/ROOT/images/solutions/container-control-panel/Makefile
  77. 34 0
      docs/modules/ROOT/images/solutions/container-control-panel/config.yaml
  78. 2 0
      docs/modules/ROOT/images/solutions/container-control-panel/containers.json
  79. BIN
      docs/modules/ROOT/images/solutions/container-control-panel/preview.png
  80. 11 0
      docs/modules/ROOT/images/solutions/container-control-panel/screenshots.ini
  81. 42 0
      docs/modules/ROOT/images/solutions/container-control-panel/setup_preview.py
  82. 2 0
      docs/modules/ROOT/images/solutions/human-in-the-control-loop/.gitignore
  83. 2 0
      docs/modules/ROOT/images/solutions/human-in-the-control-loop/Makefile
  84. 33 0
      docs/modules/ROOT/images/solutions/human-in-the-control-loop/config.yaml
  85. BIN
      docs/modules/ROOT/images/solutions/human-in-the-control-loop/preview.png
  86. 11 0
      docs/modules/ROOT/images/solutions/human-in-the-control-loop/screenshots.ini
  87. 34 0
      docs/modules/ROOT/images/solutions/human-in-the-control-loop/setup_preview.py
  88. 2 0
      docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/.gitignore
  89. 2 0
      docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/Makefile
  90. 30 0
      docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/config.yaml
  91. BIN
      docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/preview.png
  92. 11 0
      docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/screenshots.ini
  93. 42 0
      docs/modules/ROOT/images/solutions/k8s-control-panel-hosted/setup_preview.py
  94. 2 0
      docs/modules/ROOT/images/solutions/systemd-control-panel/.gitignore
  95. 2 0
      docs/modules/ROOT/images/solutions/systemd-control-panel/Makefile
  96. 33 0
      docs/modules/ROOT/images/solutions/systemd-control-panel/config.yaml
  97. BIN
      docs/modules/ROOT/images/solutions/systemd-control-panel/preview.png
  98. 11 0
      docs/modules/ROOT/images/solutions/systemd-control-panel/screenshots.ini
  99. 42 0
      docs/modules/ROOT/images/solutions/systemd-control-panel/setup_preview.py
  100. 4 0
      docs/modules/ROOT/images/solutions/systemd-control-panel/systemd_units.json

+ 1 - 1
config.yaml

@@ -12,7 +12,7 @@ logLevel: "INFO"
 # Actions are commands that are executed by OliveTin, and normally show up as
 # buttons on the WebUI.
 #
-# Docs: https://docs.olivetin.app/action_execution/create_your_first.html
+# Docs: https://docs.olivetin.app/action_buttons/create_your_first.html
 actions:
   # Lots of people use OliveTin to build web interfaces for their electronics
   # projects. It's best to install OliveTin as a native package (eg, .deb), and

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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


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


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

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

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

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

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


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


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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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


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

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

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

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

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

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

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

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

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


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


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


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


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

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

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

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

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

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

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


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

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

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

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

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

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

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


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


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

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

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

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

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

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

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


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


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


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

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

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

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

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

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

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

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

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

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

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


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


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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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


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

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

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

@@ -0,0 +1,42 @@
+#!/usr/bin/env python3
+"""Show the default Actions dashboard for the Kubernetes control panel."""
+
+import time
+
+from selenium.webdriver.common.by import By
+from selenium.webdriver.support.ui import WebDriverWait
+
+ACTION_TITLES = [
+    "get pods",
+    "restart postgres deployment",
+    "evacuate node",
+]
+
+
+def _wait_for_dashboard(driver, timeout=30):
+    WebDriverWait(driver, timeout).until(
+        lambda d: d.execute_script("return !!window.client")
+    )
+    WebDriverWait(driver, timeout).until(
+        lambda d: bool(d.find_element(By.TAG_NAME, "body").get_attribute("loaded-dashboard"))
+    )
+
+
+def _wait_for_actions(driver, timeout=30):
+    def ready(d):
+        for title in ACTION_TITLES:
+            try:
+                button = d.find_element(By.CSS_SELECTOR, f'[title="{title}"]')
+            except Exception:
+                return False
+            if not button.is_displayed():
+                return False
+        return len(d.find_elements(By.CSS_SELECTOR, ".action-button button")) >= 3
+
+    WebDriverWait(driver, timeout).until(ready)
+
+
+def run(driver):
+    _wait_for_dashboard(driver)
+    _wait_for_actions(driver)
+    time.sleep(0.2)

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

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

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

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

+ 33 - 0
docs/modules/ROOT/images/solutions/systemd-control-panel/config.yaml

@@ -0,0 +1,33 @@
+---
+listenAddressSingleHTTPFrontend: 0.0.0.0:11337
+
+logLevel: "WARN"
+checkForUpdates: false
+showFooter: false
+
+actions:
+  - title: Stop {{ systemd_unit.unit }}
+    shell: echo "stop {{ systemd_unit.unit }}"
+    icon: <iconify-icon icon="zondicons:hand-stop"></iconify-icon>
+    entity: systemd_unit
+
+  - title: Start {{ systemd_unit.unit }}
+    shell: echo "start {{ systemd_unit.unit }}"
+    icon: <iconify-icon icon="ic:round-directions-run"></iconify-icon>
+    entity: systemd_unit
+
+entities:
+  - file: 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 }}

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


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

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

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

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

+ 4 - 0
docs/modules/ROOT/images/solutions/systemd-control-panel/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"}

この差分においてかなりの量のファイルが変更されているため、一部のファイルを表示していません